Base solution for your next web application
Open Closed

Upgarde from version 14.3 to 15.3 #12679


User avatar
0
WirelessDynamics created

hello All, i am currently upgrading an application which uses asp zero 14.3 to 15.3 (.net - angular template ),We resolved all the backend and front end merge conflicts with the new template and my current dev branch the backend runs but the front end i was surprised with all the changes in p-table signals and dependency injection, routing and data binding moving away from modules to standalone components so is there a way to upgrade my old components in angular without going throw each file doing these changes its like building the application from scratch again i have a lot of complicated components the asp zero power tool regeneration of components also does not work correctly i tried it can u guys create like a Claude skill to upgrade each component like modal , please follow back if i am doing something wrong in the upgrade , i want the upgrade to get rate limiting features and active sessions without the need to re create my old entities one by one again.

Markdown is supported
Copy & paste or drag & drop images (max 30 MB per image)

5 Answer(s)
  • User Avatar
    0
    maliming created
    Support Team

    Hi,

    You do not need to rewrite your components. Two settings in the 15.3 template are what make it look that way, and both are optional. Do this to get running.

    1. Put your 14.3 strictness back

    In angular/src/tsconfig.json, remove "strict": true and typeCheckHostBindings, and set these back to false:

    strictInputTypes, strictOutputEventTypes, strictAttributeTypes,
    strictDomEventTypes, strictLiteralTypes, strictInjectionParameters
    

    Compile-time only, no runtime effect.

    2. Go back to zone-based change detection

    // package.json
    "zone.js": "~0.16.0"
    
    // angular.json — build target options
    "polyfills": ["zone.js"]
    
    // main.ts
    import { provideZoneChangeDetection } from '@angular/core';
    // provideZonelessChangeDetection(),
    provideZoneChangeDetection(),
    

    Your components now need no edits — no signals, no state conversion. The signal-based components that shipped with 15.3 keep working next to them.

    3. Let the schematics do the module cleanup

    ng generate @angular/core:standalone            # choose the step that removes unnecessary NgModules
    ng generate @angular/core:route-lazy-loading
    ng generate @angular/core:inject-function
    ng generate @angular/core:control-flow
    ng generate @angular/core:cleanup-unused-imports
    

    Run them one at a time and read each diff. Feature modules of your own that hold providers, initializers or route-scoped config need a manual look; the ones from the template do not.

    4. Fix Power Tools

    Set ProjectVersion in aspnet-core/AspNetZeroRadTool/config.json to your current version. It usually still reads a 14.x value after a merge, and Power Tools branches on that field — which is why it writes {Entity}.module.ts, {Entity}-routing.module.ts and app-shared.module imports into a solution that no longer has them, and never registers the route or the proxy.

    Leave the service-proxy.module.ts, {menu}.module.ts and {menu}-routing.module.ts entries under FileLocations.Angular as they are.

    Then update the tool from the Extensions panel in your IDE. If you run it as a DLL, replace aspnet-core/AspNetZeroRadTool/AspNetZeroRadTool.dll with the current one from the repository, using the download button on the GitHub page rather than the URL.

    In generated list components, two edits are needed once you turn strictness back on:

    import { TableLazyLoadEvent } from 'primeng/table';   // not LazyLoadEvent from primeng/api
    getProducts(event?: TableLazyLoadEvent) { ... }
    
    this.primengTableHelper.getSorting(this.dataTable()!);          // viewChild is Signal<T | undefined>
    this.primengTableHelper.getSkipCount(this.paginator()!, event!);
    

    Rate limiting and active sessions

    Neither depends on the Angular work. Active sessions is the UserSession entity and its migration, UserSessionManager, UserSessionAppService and one Angular page. Rate limiting is RateLimitPolicy with its migration, the app service and the cache manager under Web.Core/RateLimiting, plus two Angular files. Both are additive — no existing entity gets recreated. Take them once the app builds.

    Later, at your own pace

    Convert components to signals one at a time, then switch provideZoneChangeDetection() back to provideZonelessChangeDetection(). [(ngModel)]="x" keeps working when x becomes a writable signal, so templates change less than you would expect. Turn the strict flags back on in the same way, in batches.

    Two things to know when you get there:

    • AppComponentBase has a parameterless constructor and no injector property, so super(injector) goes and this.injector.get(X) becomes inject(X) at field level.
    • PrimengTableHelper.records, totalRecordsCount and isLoading are signals: .records.set(result.items) to write, records() to read.

    The AI skills that ship with the template help here — .claude/, .agent/, .cursor/ and .windsurf/ at the repository root, which a merge covering only src would have skipped. Copy them from the 15.3 download if they are missing. Note that angular-primeng-table-patterns and angular-service-proxy-patterns in the 15.3 drop still show the pre-signal primengTableHelper.records = result.items form and are being corrected; angular/src/app/admin/users/users.component.ts is the shape to follow.

    Thanks,

    Markdown is supported
    Copy & paste or drag & drop images (max 30 MB per image)
  • User Avatar
    0
    WirelessDynamics created

    hi there,

    i followed these steps 1-Put your 14.3 strictness back 2. Go back to zone-based change detection 3. Let the schematics do the module cleanup

    And it fixed the injections in the app, but it did not do anything for the p-table that expects signals instead of just variables. Also, I found that nothing adds any routes inside app.routes.ts; all the routes which was inside app-navigation.service.ts now need to be imported inside the app routes folder with an explicit route for the create or edit page and view page routes. There is no loadChildren; it must be loadComponent If I am doing something wrong, can use the already created routing modules that got generated from ng generate @angular/core:route-lazy-loading u can tell me the correct way to manage routes, and if there is a way to make old p-tables still work without signals or can you guys make a Claude skill to change the old p-table to a new one with signals or just a command

    Markdown is supported
    Copy & paste or drag & drop images (max 30 MB per image)
  • User Avatar
    0
    maliming created
    Support Team

    Hi,

    You are not doing anything wrong — those three steps were the right ones, and they did what they were meant to do. The table helper is a separate problem they were never going to fix: PrimengTableHelper itself changed, records, totalRecordsCount and isLoading are signals now. With the 15.3 helper in place the old access syntax is a type error, and neither the change detection mode nor the relaxed strictness affects it:

    TS2739: Type 'any[]' is missing the following properties from type
            'WritableSignal<any[]>': set, update, asReadonly, ...
        this.primengTableHelper.records = result.items;
    
    TS2322: Type 'number' is not assignable to type 'WritableSignal<number>'.
        this.primengTableHelper.totalRecordsCount = result.totalCount;
    

    To answer the question directly: keeping the old p-tables would mean putting the 14.3 PrimengTableHelper.ts back, and the 15.3 template uses the signal API in 59 of its own files, so you would be converting those in the opposite direction instead. Converting your own components is the smaller job, and most of it is scriptable.

    The compiler catches most of it, but not every old read. this.primengTableHelper.records.length still compiles, because records is a function now and Function.length is a valid number — it returns 0 at runtime. So if (this.primengTableHelper.records && this.primengTableHelper.records.length > 0) quietly becomes false. A template comparison like @if (primengTableHelper.totalRecordsCount == 0) does fail the build with TS2367, so that one you will see.

    p-table

    This script covers the standard PrimengTableHelper access patterns. It parses the .ts files with the TypeScript compiler, so comments, string literals, ===, arrow bodies and missing semicolons are handled properly. It reports by default and writes only with --write. Save it at the root of angular/ and run node signal-table.mjs src/app:

    // signal-table.mjs
    // .ts files go through the TypeScript parser, so comments, strings, template
    // literals, ===, arrow bodies and missing semicolons are all handled correctly.
    import { readdirSync, statSync, readFileSync, writeFileSync } from 'fs';
    import { join } from 'path';
    import ts from 'typescript';
    
    const PROPS = new Set(['records', 'totalRecordsCount', 'isLoading']);
    const NAME = /^primengTableHelper[A-Za-z0-9_]*$/;
    const isHelper = (n) =>
      (ts.isIdentifier(n) && NAME.test(n.text)) ||                       // primengTableHelper.records
      (ts.isPropertyAccessExpression(n) && NAME.test(n.name.text));      // this.primengTableHelper.records
    const MUTATORS = new Set(['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse', 'fill', 'copyWithin']);
    
    function convertTs(src, file, manual) {
      const sf = ts.createSourceFile(file, src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
      const edits = [];
      const line = (pos) => sf.getLineAndCharacterOfPosition(pos).line + 1;
    
      (function visit(node) {
        if (ts.isPropertyAccessExpression(node) && isHelper(node.expression) && PROPS.has(node.name.text)) {
          const p = node.parent;
    
          // write: helper.records = <expr>
          if (ts.isBinaryExpression(p) && p.operatorToken.kind === ts.SyntaxKind.EqualsToken && p.left === node) {
            const rhs = p.right.getText(sf);
            edits.push({ start: p.getStart(sf), end: p.getEnd(), text: `${node.getText(sf)}.set(${rhs})` });
            return;
          }
          // compound assignment, ++/--, element write, in-place mutators: report only
          if (ts.isBinaryExpression(p) && p.left === node && p.operatorToken.kind !== ts.SyntaxKind.EqualsToken
              && p.operatorToken.getText(sf).endsWith('=') && !['==', '===', '!=', '!=='].includes(p.operatorToken.getText(sf))) {
            manual.push(`${file}:${line(node.getStart(sf))}  ${p.getText(sf).split('\n')[0]}`);
            return;
          }
          if ((ts.isPrefixUnaryExpression(p) || ts.isPostfixUnaryExpression(p))) {
            manual.push(`${file}:${line(node.getStart(sf))}  ${p.getText(sf).split('\n')[0]}`);
            return;
          }
          if (ts.isPropertyAccessExpression(p) && MUTATORS.has(p.name.text)) {
            manual.push(`${file}:${line(node.getStart(sf))}  ${p.getText(sf).split('\n')[0]}`);
            return;
          }
          // writing through the signal: records[i] = x, records[i].field = x, records[i].a.b = x
          if (ts.isElementAccessExpression(p) && p.expression === node) {
            let top = p;
            while (ts.isPropertyAccessExpression(top.parent) || ts.isElementAccessExpression(top.parent)) top = top.parent;
            if (ts.isBinaryExpression(top.parent) && top.parent.left === top
                && top.parent.operatorToken.getText(sf).endsWith('=')
                && !['==', '===', '!=', '!=='].includes(top.parent.operatorToken.getText(sf))) {
              manual.push(`${file}:${line(node.getStart(sf))}  ${top.parent.getText(sf).split('\n')[0]}`);
              return;
            }
          }
          // already migrated: .set / .update / .asReadonly, or already called
          if (ts.isPropertyAccessExpression(p) && ['set', 'update', 'asReadonly'].includes(p.name.text)) return;
          if (ts.isCallExpression(p) && p.expression === node) return;
    
          // everything else is a read
          edits.push({ start: node.getEnd(), end: node.getEnd(), text: '()' });
          return;
        }
        ts.forEachChild(node, visit);
      })(sf);
    
      if (!edits.length) return src;
      let out = src;
      for (const e of edits.sort((a, b) => b.start - a.start)) {
        out = out.slice(0, e.start) + e.text + out.slice(e.end);
      }
      return out;
    }
    
    // HTML: comments and anything that can assign (event bindings, banana-in-a-box)
    // are masked out and reported; every remaining read gets its call parentheses,
    // which covers [prop]="...", {{ }} and the @if / @for control flow blocks.
    const HELPER_READ =
      /(?<![A-Za-z0-9_$])(primengTableHelper[A-Za-z0-9_]*)\.(records|totalRecordsCount|isLoading)(?!\s*\()(?!\.(?:set|update|asReadonly)\b)/g;
    
    function convertHtml(src, file, manual) {
      const box = [];
      const hide = (s) => `__PTMASK_${box.push(s) - 1}__`;
    
      let t = src.replace(/<!--[\s\S]*?-->/g, hide);
      t = t.replace(/(?:\[\([\w.-]+\)\]|\([\w.-]+\))="[^"]*"/g, (s) => {
        if (HELPER_READ.test(s)) manual.push(`${file}  ${s.trim()}`);
        HELPER_READ.lastIndex = 0;
        return hide(s);
      });
    
      t = t.replace(HELPER_READ, (_, h, p) => `${h}.${p}()`);
      return t.replace(/__PTMASK_(\d+)__/g, (_, i) => box[+i]);
    }
    
    // anything still reading the old way after the pass, e.g. through a local alias
    const LEFTOVER = /\.(records|totalRecordsCount|isLoading)\b(?!\s*\()(?!\.(?:set|update|asReadonly)\b)/;
    
    const write = process.argv.includes('--write');
    const manual = [];
    const leftover = [];
    let changed = 0;
    
    (function walk(dir) {
      for (const entry of readdirSync(dir)) {
        const p = join(dir, entry);
        if (statSync(p).isDirectory()) { walk(p); continue; }
        const html = /\.html$/.test(entry);
        if (!html && !/\.ts$/.test(entry)) continue;
        const before = readFileSync(p, 'utf8');
        const after = html ? convertHtml(before, p, manual) : convertTs(before, p, manual);
        if (after !== before) {
          changed++;
          if (write) { writeFileSync(p, after); console.log('updated ' + p); }
          else console.log('would update ' + p);
        }
        after.split('\n').forEach((l, i) => {
          if (LEFTOVER.test(l)) leftover.push(`${p}:${i + 1}  ${l.trim()}`);
        });
      }
    })(process.argv[2] || 'src/app');
    
    if (manual.length) {
      console.log('\nchange these by hand, the script will not touch them:');
      manual.forEach((m) => console.log('  ' + m));
    }
    if (leftover.length) {
      console.log('\nstill reading the old way, check these too:');
      leftover.forEach((m) => console.log('  ' + m));
    }
    console.log(`\n${changed} file(s) ${write ? 'updated' : 'would change'}, ` +
                `${manual.length} to convert by hand, ${leftover.length} to check`);
    

    It rewrites the assignments to .records.set(result.items) and the reads — including records.length, the property bindings and the @if / @for blocks — to records(). Pages with two tables are covered, since it matches primengTableHelperAuditLogs and similar names. Running it twice changes nothing the second time.

    It reports rather than rewrites in-place mutation and compound assignment: records.push(...), splice, sort, totalRecordsCount += 1, and writes through the array such as records[i] = x or records[i].someField = x. Those cannot be left as they are either — records is a signal function, so records.push(...) throws TypeError: records.push is not a function at runtime. Change them to records.set([...]) or records.update(...).

    It matches the helper by name, so a component that copies it into a local variable first (const helper = this.primengTableHelper;) is not converted. Those lines come out under a second list at the end of the run, along with anything else still reading the old way.

    Run it without --write first and read the report. Then apply it on a clean working tree, work through the reported spots, build, and open a couple of list pages to check paging, sorting and the loading indicator before you move on.

    Routes

    app-navigation.service.ts defines menu items, not Angular routes, so nothing moves out of it.

    loadComponent is for a single component route. For this standalone setup loadChildren can return the exported Routes array, so keep the file the schematic generated for you and drop the module wrapper:

    // gate.routes.ts  — was gate-routing.module.ts
    import { Routes } from '@angular/router';
    
    export const gateRoutes: Routes = [
        { path: '', loadComponent: () => import('./gates.component').then(m => m.GatesComponent), pathMatch: 'full' },
        { path: 'createOrEdit', loadComponent: () => import('./create-or-edit-gate.component').then(m => m.CreateOrEditGateComponent) },
        { path: 'view', loadComponent: () => import('./view-gate.component').then(m => m.ViewGateComponent) },
    ];
    

    The array is what you already have. import { NgModule }, the @NgModule block and RouterModule.forChild(routes) go away, and const routes becomes an exported const. If anything still imports GateRoutingModule, that import has to go too.

    Then reference it from app.routes.ts, in the children array of main or admin, where your old main-routing.module.ts entry used to sit:

    {
        path: 'main',
        data: { preload: true },
        children: [
            {
                path: 'catalog/gates',
                loadChildren: () => import('./main/catalog/gates/gate.routes').then(m => m.gateRoutes),
                data: { permission: 'Pages.Gates' },
            },
            // ...
        ],
    },
    

    That gives you /app/main/catalog/gates, /app/main/catalog/gates/createOrEdit and /app/main/catalog/gates/view, and the one data: { permission: ... } on the parent entry reaches the guard on all three — you do not need to repeat it per page.

    Two ways to get this wrong:

    • Do not put pathMatch: 'full' on the catalog/gates entry itself. The list page still loads and the child URLs render nothing.
    • Put these entries above any ** route in the same children array, or the wildcard takes them.

    Do carry the data: { permission: ... } across. In 14.3 it sat on the loadChildren line in main-routing.module.ts, not inside the feature routing module, which is why your gate-routing.module.ts does not have it. AppRouteGuard allows the navigation when the permission is absent, so a missing one is not an error you will see — it just stops enforcing a permission for that route. Route data is not a security boundary in any case; the API endpoints have to enforce the permission as well.

    Power Tools writes routes flat instead — one loadComponent entry per page directly in that same children array, including separate entries for createOrEdit and view. Both shapes produce the same URLs, so keep loadChildren for what you already have and let it use the flat form for anything you generate later.

    If the report lists anything you are not sure how to convert, paste it here with the build output and I will tell you what each one needs. Same for your app.routes.ts if the main or admin children look different from the above.

    Thanks,

    Markdown is supported
    Copy & paste or drag & drop images (max 30 MB per image)
  • User Avatar
    0
    WirelessDynamics created

    hello asp zero team ,

    i have a follow up question regarding the upgrade to aspzero15.3 i have old file upload components that follow this tutorial https://docs.aspnetzero.com/aspnet-core-angular/latest/Core-Angular-File-Upload-Tutorial

    now after upgrading, i noticed that library :

    ng2-file-upload
    import { FileUploader, FileUploaderOptions, FileItem } from 'ng2-file-upload';

    does not exist in the package.json file

    even after reinstalling it the package and sending the request to the controller the controller does not see the jwt bearer token for example this method I added all these variable to debug the authentication and is authenticated is false and all variables are null

    public async Task<List<string>> UploadOrgFile(int pageLevel, bool isFirstRow)

    { try { var isAuthenticated = User?.Identity?.IsAuthenticated;

          var userId = User?.FindFirst(
              AbpClaimTypes.UserId
          )?.Value;
    
          var tenantId = User?.FindFirst(
              AbpClaimTypes.TenantId
          )?.Value;
    
          var abpUserId = AbpSession.UserId;
          var abpTenantId = AbpSession.TenantId;
    
          IFormFile file = Request.Form.Files[0];
          var x = await assetOrgsAppService.UploadExcellAssetOrgs(file, pageLevel, isFirstRow);
          return x;
      }
      catch (Exception ex)
      {
          List&lt;string&gt; e = new List&lt;string&gt;();
          e.Add(ex.Message);
          return e;
      }
    

    } Can you please upgrade this tutorial to match the new changes in the 15.3 version https://docs.aspnetzero.com/aspnet-core-angular/latest/Core-Angular-File-Upload-Tutorial

    Markdown is supported
    Copy & paste or drag & drop images (max 30 MB per image)
  • User Avatar
    0
    maliming created
    Support Team

    Hi,

    One correction to my first answer before the upload part. 15.3 pins TypeScript 6, and TypeScript 6 turns strict on by default, so deleting "strict": true does not switch it off — it has to be set explicitly, in angular/src/tsconfig.json:

    "strict": false
    

    That is the file to edit; the tsconfig.json in the angular root is solution-style and carries no compiler options. The angularCompilerOptions block in the same file is not affected by strict and needs its own pass — strictTemplates and fullTemplateTypeCheck first, then strictInputTypes, strictOutputEventTypes, strictAttributeTypes, strictDomEventTypes, strictLiteralTypes, strictInjectionParameters and the new typeCheckHostBindings.

    Why the token is missing

    ng2-file-upload was dropped in 15.3 — the two components that used it, the profile picture modal and the logo upload in tenant settings, both moved to HttpClient.

    FileUploader sends through a raw XMLHttpRequest and never imports @angular/common/http, so the interceptor chain does not see the request. The bearer token is there only because the tutorial sets it by hand:

    this._uploaderOptions.authToken = 'Bearer ' + this._tokenService.getToken();
    

    HttpClient takes a different path. main.ts has provideHttpClient(withInterceptorsFromDi()), service-proxy.providers.ts registers ZeroTemplateHttpInterceptor, and the AbpHttpInterceptor it extends adds Authorization: Bearer <token> to any request that does not already carry one, with no URL filtering — your own controllers included.

    UploadOrgFile itself

    Your action body runs and sees no user. A protected endpoint would not have let the request that far — without a token it is a 302 to the login page and the method never executes. The upload controllers in 15.3 carry the attribute: ProfileController, TenantCustomizationController and ChatController are all [AbpMvcAuthorize], and the tutorial's own controller is [AbpMvcAuthorize(AppPermissions.Pages_FileUpload)]. Add it, and anonymous requests stop reaching your code at all.

    The other one will bite as soon as uploads work:

    catch (Exception ex)
    {
        List<string> e = new List<string>();
        e.Add(ex.Message);
        return e;
    }
    

    That returns a normal List<string>, so ABP wraps it as a successful AjaxResponse. The client cannot tell a failure from a success — it gets success: true with the exception text sitting in result. Let the exception propagate, or throw UserFriendlyException, and ABP produces a real error response.

    While you are in there, 15.3's upload endpoints check the file before using it — empty file, then IFileValidatorManager.ValidateAll(new FileValidateInput(file)) for size and content. Request.Form.Files[0] on an empty collection throws.

    The Angular side

    private _httpClient = inject(HttpClient);
    
    private readonly uploadUrl = AppConsts.remoteServiceBaseUrl + '/YourController/UploadOrgFile';
    
    readonly selectedFile = signal<File | null>(null);
    readonly saving = signal<boolean>(false);
    
    onFileSelected(event: Event): void {
        this.selectedFile.set((event.target as HTMLInputElement).files?.[0] ?? null);
    }
    
    save(): void {
        const file = this.selectedFile();
        if (!file) {
            return;
        }
    
        const formData = new FormData();
        formData.append('file', file, file.name);
        formData.append('pageLevel', this.pageLevel().toString());
        formData.append('isFirstRow', this.isFirstRow().toString());
    
        this.saving.set(true);
        this._httpClient
            .post<any>(this.uploadUrl, formData)
            .pipe(finalize(() => this.saving.set(false)))
            .subscribe({
                next: (response) => {
                    if (response.success) {
                        this.message.success(this.l('FileSavedSuccessfully'));
                    } else {
                        this.message.error(response?.error?.message);
                    }
                },
                error: (error) => {
                    this.message.error(error?.error?.message);
                },
            });
    }
    

    Whatever onBuildItemForm used to append goes into the same FormData — model binding reads pageLevel and isFirstRow from the multipart form, and Request.Form.Files[0] still finds the file, so the field name does not matter. Do not set a Content-Type header yourself; the browser has to generate the multipart boundary.

    Keep the error callback. Without it a failed upload shows Server is unreachable from the interceptor, which is what it says for any non-blob error response — the generated service proxies ask for blob and take a different branch. That message will send you looking at the network when the real answer is in the status code.

    In the template, every reference to the old uploader goes — the ng2FileSelect directive, uploader.queue, progress bars, uploadAll():

    <input class="form-control" type="file" (change)="onFileSelected($event)" />
    
    <button type="submit" [disabled]="!selectedFile() || saving()">
        {{ 'Upload' | localize }}
    </button>
    

    !selectedFile() matters there: a bare <input type="file" required> is never registered with NgForm, so form.valid ignores it. The tutorial has that bug today, and it is out of date on everything above.

    This covers one file with no queue, progress or cancellation. Progress is reportProgress: true with observe: 'events'; cancellation means holding the subscription.

    One more thing to sweep for: 14.3 imported ng2-file-upload in app.module.ts, app-shared.module.ts and admin-shared.module.ts. If any of those imports survived your merge, they have to go with the package.

    Send me the code

    For the rest of your components, going error by error over several rounds is the slow way. Send the code to [email protected] and I will do the migration pass on it here.

    The angular folder is what I need — src, plus package.json, angular.json and src/tsconfig.json. If the whole folder is a problem, src/app and src/shared cover nearly all of it. Add the full controller class that has UploadOrgFile, not only the action. If parts of it cannot leave your network, empty out the service bodies and leave the components; the migration work sits in the components, not in what they call.

    What comes back is the changed files — the PrimengTableHelper signal changes, the AppComponentBase constructor shape, the ng2-file-upload rewrites and the routing moves — and a separate list of what needs your decision rather than mine: your own business logic, and any third-party Angular packages you pulled in on top of 14.3.

    One thing the code cannot show is whether the upload request actually carries the header. If you want that settled before the rewrite, do one failing upload with the Network tab open and send the request headers with the token value replaced by Bearer [redacted], along with the status code. No header at all means the request never carried one; Bearer null means the token was already gone before the upload started.

    Rate limiting and active sessions neither need an existing entity regenerated, and I can write both up against your own merged code once it builds.

    Thanks,

    Markdown is supported
    Copy & paste or drag & drop images (max 30 MB per image)