@aglyn/aglyn 1.0.0-beta.296 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,3 +29,33 @@
29
29
  export declare const VERSION_DRAFT_COLLECTION = "draft";
30
30
  /** The only id a draft may have. One per version, by construction. */
31
31
  export declare const VERSION_DRAFT_DOC_ID = "current";
32
+ /**
33
+ * The query a link into the Besigner carries to open the version's saved
34
+ * draft on arrival (AGL-4116), rather than leave it behind the Open draft
35
+ * banner. An AI edit's Open in the Besigner uses it: the person asked for the
36
+ * change, so the change is what they should land on.
37
+ */
38
+ export declare const BESIGNER_OPEN_DRAFT_PARAM = "draft";
39
+ /** The value of {@link BESIGNER_OPEN_DRAFT_PARAM} that asks for the draft to open. */
40
+ export declare const BESIGNER_OPEN_DRAFT_VALUE = "open";
41
+ /** A Besigner address that opens the version's saved draft on arrival. */
42
+ export declare function besignerOpenDraftHref(href: string): string;
43
+ /** The version segment of a document with no versions of its own, as the mirror writes it. */
44
+ export declare const COEDIT_MIRROR_CURRENT_VERSION = "current";
45
+ /**
46
+ * The co-edit mirror room of one version (AGL-677): the unsaved canvas work
47
+ * a Besigner tab publishes for the others in the room, replayed to whoever
48
+ * joins until a save clears it. `apps/console/hooks/use-coediting.ts` builds
49
+ * the same path, and `presence-version-scope.spec.ts` holds the two together.
50
+ *
51
+ * Saving makes the stored document authoritative, so a save clears the
52
+ * room; an AI edit job that saves into a version's draft clears it too
53
+ * (AGL-4116), or the older unsaved work replays over the draft it just wrote.
54
+ */
55
+ export declare function coeditMirrorRoomPath(input: {
56
+ orgId: string;
57
+ hostId: string;
58
+ docType: string;
59
+ docId: string;
60
+ versionId?: string | null;
61
+ }): string;
@@ -25,5 +25,29 @@
25
25
  * it in the Besigner exactly as they would their own (AGL-4116).
26
26
  */ /** The subcollection under a version that holds its draft. */ export const VERSION_DRAFT_COLLECTION = 'draft';
27
27
  /** The only id a draft may have. One per version, by construction. */ export const VERSION_DRAFT_DOC_ID = 'current';
28
+ /**
29
+ * The query a link into the Besigner carries to open the version's saved
30
+ * draft on arrival (AGL-4116), rather than leave it behind the Open draft
31
+ * banner. An AI edit's Open in the Besigner uses it: the person asked for the
32
+ * change, so the change is what they should land on.
33
+ */ export const BESIGNER_OPEN_DRAFT_PARAM = 'draft';
34
+ /** The value of {@link BESIGNER_OPEN_DRAFT_PARAM} that asks for the draft to open. */ export const BESIGNER_OPEN_DRAFT_VALUE = 'open';
35
+ /** A Besigner address that opens the version's saved draft on arrival. */ export function besignerOpenDraftHref(href) {
36
+ return `${href}?${BESIGNER_OPEN_DRAFT_PARAM}=${BESIGNER_OPEN_DRAFT_VALUE}`;
37
+ }
38
+ /** The version segment of a document with no versions of its own, as the mirror writes it. */ export const COEDIT_MIRROR_CURRENT_VERSION = 'current';
39
+ /**
40
+ * The co-edit mirror room of one version (AGL-677): the unsaved canvas work
41
+ * a Besigner tab publishes for the others in the room, replayed to whoever
42
+ * joins until a save clears it. `apps/console/hooks/use-coediting.ts` builds
43
+ * the same path, and `presence-version-scope.spec.ts` holds the two together.
44
+ *
45
+ * Saving makes the stored document authoritative, so a save clears the
46
+ * room; an AI edit job that saves into a version's draft clears it too
47
+ * (AGL-4116), or the older unsaved work replays over the draft it just wrote.
48
+ */ export function coeditMirrorRoomPath(input) {
49
+ var _input_versionId;
50
+ return `coedit/${input.orgId}/${input.hostId}/${input.docType}/${input.docId}/${(_input_versionId = input.versionId) != null ? _input_versionId : COEDIT_MIRROR_CURRENT_VERSION}/nodes`;
51
+ }
28
52
 
29
53
  //# sourceMappingURL=version-draft.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/version-draft.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Where a version's WORKING DRAFT lives (AGL-1152): one document per version,\n * `…/versions/{versionId}/draft/current`, on every plan. The Besigner saves\n * to it as an author works and offers it with Open draft when the page is\n * opened; publishing moves it into the version and clears it.\n *\n * Held here, in the core, because two writers must agree on it to the\n * letter: the Besigner (`besigner-server-draft.ts`) and an AI edit job, which\n * saves its change into the same draft so the author reviews and publishes\n * it in the Besigner exactly as they would their own (AGL-4116).\n */\n\n/** The subcollection under a version that holds its draft. */\nexport const VERSION_DRAFT_COLLECTION = 'draft'\n\n/** The only id a draft may have. One per version, by construction. */\nexport const VERSION_DRAFT_DOC_ID = 'current'\n"],"names":["VERSION_DRAFT_COLLECTION","VERSION_DRAFT_DOC_ID"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;CAUC,GAED,4DAA4D,GAC5D,OAAO,MAAMA,2BAA2B,QAAO;AAE/C,oEAAoE,GACpE,OAAO,MAAMC,uBAAuB,UAAS"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/version-draft.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Where a version's WORKING DRAFT lives (AGL-1152): one document per version,\n * `…/versions/{versionId}/draft/current`, on every plan. The Besigner saves\n * to it as an author works and offers it with Open draft when the page is\n * opened; publishing moves it into the version and clears it.\n *\n * Held here, in the core, because two writers must agree on it to the\n * letter: the Besigner (`besigner-server-draft.ts`) and an AI edit job, which\n * saves its change into the same draft so the author reviews and publishes\n * it in the Besigner exactly as they would their own (AGL-4116).\n */\n\n/** The subcollection under a version that holds its draft. */\nexport const VERSION_DRAFT_COLLECTION = 'draft'\n\n/** The only id a draft may have. One per version, by construction. */\nexport const VERSION_DRAFT_DOC_ID = 'current'\n\n/**\n * The query a link into the Besigner carries to open the version's saved\n * draft on arrival (AGL-4116), rather than leave it behind the Open draft\n * banner. An AI edit's Open in the Besigner uses it: the person asked for the\n * change, so the change is what they should land on.\n */\nexport const BESIGNER_OPEN_DRAFT_PARAM = 'draft'\n\n/** The value of {@link BESIGNER_OPEN_DRAFT_PARAM} that asks for the draft to open. */\nexport const BESIGNER_OPEN_DRAFT_VALUE = 'open'\n\n/** A Besigner address that opens the version's saved draft on arrival. */\nexport function besignerOpenDraftHref(href: string): string {\n return `${href}?${BESIGNER_OPEN_DRAFT_PARAM}=${BESIGNER_OPEN_DRAFT_VALUE}`\n}\n\n/** The version segment of a document with no versions of its own, as the mirror writes it. */\nexport const COEDIT_MIRROR_CURRENT_VERSION = 'current'\n\n/**\n * The co-edit mirror room of one version (AGL-677): the unsaved canvas work\n * a Besigner tab publishes for the others in the room, replayed to whoever\n * joins until a save clears it. `apps/console/hooks/use-coediting.ts` builds\n * the same path, and `presence-version-scope.spec.ts` holds the two together.\n *\n * Saving makes the stored document authoritative, so a save clears the\n * room; an AI edit job that saves into a version's draft clears it too\n * (AGL-4116), or the older unsaved work replays over the draft it just wrote.\n */\nexport function coeditMirrorRoomPath(input: {\n orgId: string\n hostId: string\n docType: string\n docId: string\n versionId?: string | null\n}): string {\n return `coedit/${input.orgId}/${input.hostId}/${input.docType}/${input.docId}/${input.versionId ?? COEDIT_MIRROR_CURRENT_VERSION}/nodes`\n}\n"],"names":["VERSION_DRAFT_COLLECTION","VERSION_DRAFT_DOC_ID","BESIGNER_OPEN_DRAFT_PARAM","BESIGNER_OPEN_DRAFT_VALUE","besignerOpenDraftHref","href","COEDIT_MIRROR_CURRENT_VERSION","coeditMirrorRoomPath","input","orgId","hostId","docType","docId","versionId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;CAUC,GAED,4DAA4D,GAC5D,OAAO,MAAMA,2BAA2B,QAAO;AAE/C,oEAAoE,GACpE,OAAO,MAAMC,uBAAuB,UAAS;AAE7C;;;;;CAKC,GACD,OAAO,MAAMC,4BAA4B,QAAO;AAEhD,oFAAoF,GACpF,OAAO,MAAMC,4BAA4B,OAAM;AAE/C,wEAAwE,GACxE,OAAO,SAASC,sBAAsBC,IAAY;IAChD,OAAO,GAAGA,KAAK,CAAC,EAAEH,0BAA0B,CAAC,EAAEC,2BAA2B;AAC5E;AAEA,4FAA4F,GAC5F,OAAO,MAAMG,gCAAgC,UAAS;AAEtD;;;;;;;;;CASC,GACD,OAAO,SAASC,qBAAqBC,KAMpC;QACiFA;IAAhF,OAAO,CAAC,OAAO,EAAEA,MAAMC,KAAK,CAAC,CAAC,EAAED,MAAME,MAAM,CAAC,CAAC,EAAEF,MAAMG,OAAO,CAAC,CAAC,EAAEH,MAAMI,KAAK,CAAC,CAAC,GAAEJ,mBAAAA,MAAMK,SAAS,YAAfL,mBAAmBF,8BAA8B,MAAM,CAAC;AAC1I"}
@@ -76,6 +76,20 @@ export interface PluginJobHostGate {
76
76
  * shut by infrastructure it never asked for. The loss is real and is why
77
77
  * `hasPluginJobHostLockdown` is reported by the runner route rather than
78
78
  * left to be inferred from nothing happening.
79
+ *
80
+ * ONE VERDICT PER HOST PER GATE (AGL-4121). The resolver's answer costs three
81
+ * Firestore reads (org id, org, host) and outside a request `React.cache`
82
+ * dedupes none of them, so a scan that asked once per row paid three reads per
83
+ * row for one site's answer. The runner mints a gate per job per run, so a
84
+ * verdict held here lives exactly as long as the run that asked, the way the
85
+ * sending-identity and branding maps in those scans do, and the next beat asks
86
+ * afresh. A lock laid mid-run therefore reaches a site already asked about on
87
+ * the next beat rather than on that run's next row.
88
+ *
89
+ * The promise is what is held, so askers running side by side
90
+ * (`pos-cellular-billing` asks under a `Promise.all`) share one read. A
91
+ * resolver that failed gave no verdict, so nothing is kept and the next row
92
+ * asks again, as it always has.
79
93
  */
80
94
  export declare function pluginJobHostGate(): PluginJobHostGate;
81
95
  /** Test seam: let the "warned once" latch be re-armed. */
@@ -100,15 +100,38 @@
100
100
  * shut by infrastructure it never asked for. The loss is real and is why
101
101
  * `hasPluginJobHostLockdown` is reported by the runner route rather than
102
102
  * left to be inferred from nothing happening.
103
+ *
104
+ * ONE VERDICT PER HOST PER GATE (AGL-4121). The resolver's answer costs three
105
+ * Firestore reads (org id, org, host) and outside a request `React.cache`
106
+ * dedupes none of them, so a scan that asked once per row paid three reads per
107
+ * row for one site's answer. The runner mints a gate per job per run, so a
108
+ * verdict held here lives exactly as long as the run that asked, the way the
109
+ * sending-identity and branding maps in those scans do, and the next beat asks
110
+ * afresh. A lock laid mid-run therefore reaches a site already asked about on
111
+ * the next beat rather than on that run's next row.
112
+ *
113
+ * The promise is what is held, so askers running side by side
114
+ * (`pos-cellular-billing` asks under a `Promise.all`) share one read. A
115
+ * resolver that failed gave no verdict, so nothing is kept and the next row
116
+ * asks again, as it always has.
103
117
  */ export function pluginJobHostGate() {
118
+ const verdicts = new Map();
119
+ const ask = async (hostId)=>{
120
+ if (!hostId) return false;
121
+ if (!hostLockedResolver) {
122
+ warnMissingResolver();
123
+ return false;
124
+ }
125
+ return await hostLockedResolver(hostId) === true;
126
+ };
104
127
  return {
105
- isLocked: async (hostId)=>{
106
- if (!hostId) return false;
107
- if (!hostLockedResolver) {
108
- warnMissingResolver();
109
- return false;
110
- }
111
- return await hostLockedResolver(hostId) === true;
128
+ isLocked: (hostId)=>{
129
+ const held = verdicts.get(hostId);
130
+ if (held) return held;
131
+ const verdict = ask(hostId);
132
+ verdicts.set(hostId, verdict);
133
+ verdict.catch(()=>verdicts.delete(hostId));
134
+ return verdict;
112
135
  }
113
136
  };
114
137
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-jobs.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Plugin scheduled jobs (AGL-435, Strapi cron parity): `/server` entries\n * register named jobs with an interval; a guarded platform runner route\n * (`/api/plugins/run-jobs`, secret header, invoked by the deployment's\n * scheduler) executes the due ones. The REGISTRY is pure — due-ness and\n * last-run persistence belong to the runner, so core stays storage-free.\n * Handlers must be idempotent and bounded (they share the API process).\n */\n\n/*==========================================\n * THE LOCKDOWN GATE FOR BACKGROUND WORK (AGL-2495, from the AGL-1621 drill).\n *\n * A job runs on PLATFORM credentials, from a secret-gated route, with no\n * visitor, no session and no org — so none of the gates that cover the\n * visitor-facing write paths are anywhere near it. That is exactly the shape\n * the drill found in `publish-schedule-job.ts`: a scheduled publish firing on\n * a locked host. The commerce and bookings beats are the sharper version of\n * the same thing, because they touch orders, stock and money for a host that\n * may be suspended for abuse, non-payment or a legal takedown.\n *\n * ## Why the gate lives HERE and not at the six call sites\n *\n * Six call-site edits close six holes and guarantee the seventh is forgotten.\n * Gating in the job contract means the seventh job is covered the day it is\n * written: `lockdown` below is REQUIRED, so a new `registerPluginJob({…})`\n * does not compile until its author has answered \"what does this touch\".\n *\n * (The nx boundary is NOT why. `libs/plugins/*` CAN import\n * `@aglyn/tenant-data-admin` — it carries `scope:data` and `scope:aglyn`,\n * both on the `aglyn:addons` allowlist, and 196 files in `libs/plugins`\n * already do. The AGL-2495 sweep recorded the opposite as its reason for\n * deferring; it was wrong about the constraint and right about the design.)\n *\n * ## Why core cannot just call `getSiteLockdown` itself\n *\n * `@aglyn/tenant-data-admin` imports `@aglyn/aglyn/server`, so a core import\n * of it is a project CYCLE. Hence the same registry shape the platform uses\n * for every other app↔core edge (`registerBillingWebhookHandler`,\n * `registerSitePageResolver`): the host app\n * registers the resolver from a place that may import the admin lib, and core\n * looks it up. Nothing is imported statically in either direction and a\n * self-host build with a different carrier registers its own.\n *\n * ## Fail-open is INHERITED, not re-decided\n *\n * The gate asks whatever the app registered — on the tenant that is\n * `getSiteLockdown`, which owns the fail-open/fail-closed decision (and the\n * takedown-class ratchet on top of it). This module deliberately holds no\n * parallel notion of what a lock is: it has one question and no opinion.\n *=========================================*/\n\n/**\n * The one question a beat asks before touching a host: is a lock active that\n * forbids this write? Resolved by the host app, never by core.\n */\nexport type PluginJobHostLockedResolver = (\n hostId: string,\n) => Promise<boolean> | boolean\n\nlet hostLockedResolver: PluginJobHostLockedResolver | null = null\n\n/**\n * Registers the platform's per-host lockdown verdict for background work,\n * from a place that may import the admin data lib — on this platform,\n * `apps/tenant/utils/plugin-job-lockdown.ts`, loaded for its side effect by\n * the runner route beside the core job registrations.\n *\n * Idempotent and last-registration-wins, unlike a registry that holds a\n * capability with a plugin OWNER, where a second claimant is a\n * misconfiguration. This is infrastructure the deployment supplies exactly\n * once, and a process that loads the module twice must not throw on the\n * second pass.\n */\nexport function registerPluginJobHostLockdown(\n resolver: PluginJobHostLockedResolver,\n): void {\n hostLockedResolver = resolver\n}\n\n/** Has the deployment wired a verdict? Reported by the runner route. */\nexport function hasPluginJobHostLockdown(): boolean {\n return hostLockedResolver !== null\n}\n\n/** Test seam: forget the registration. */\nexport function resetPluginJobHostLockdownForTests(): void {\n hostLockedResolver = null\n}\n\n/**\n * The gate handed to a `per-host` job. One method, so there is exactly one\n * thing to call and one thing for the coverage guard to look for:\n *\n * if (await gate.isLocked(hostId)) continue\n *\n * SKIPPED, NOT DROPPED is the caller's obligation and the reason this\n * answers a question rather than performing the skip: `continue` leaves the\n * row exactly as it was, so the work lands on the next beat after the lift.\n * A gate that swallowed the row — stamping it done, deleting it, advancing a\n * cursor past it — would turn a pause into a cancellation, which is what\n * `publish-schedule-job.ts` argued at length and what every job here copies.\n */\nexport interface PluginJobHostGate {\n isLocked(hostId: string): Promise<boolean>\n}\n\n/**\n * The gate as the runner builds it.\n *\n * The MANUAL doors — `commerce/process-abandoned`, `commerce/process-restock`,\n * `bookings/reminders` — do NOT use it (AGL-3356). They are served by the\n * console, which registers no resolver, so this answered \"not locked\" for\n * every site there and a forced pass mailed a suspended site's customers.\n * They take `siteLockdownJobGate()` from the admin lib instead: the same\n * function the tenant registers here, needing no registration at all.\n *\n * NO resolver registered answers \"not locked\", loudly and once. That is the\n * fail-open direction on purpose: a deployment that has not wired a carrier\n * (a self-host, a test process) must not have every background job welded\n * shut by infrastructure it never asked for. The loss is real and is why\n * `hasPluginJobHostLockdown` is reported by the runner route rather than\n * left to be inferred from nothing happening.\n */\nexport function pluginJobHostGate(): PluginJobHostGate {\n return {\n isLocked: async (hostId: string) => {\n if (!hostId) return false\n if (!hostLockedResolver) {\n warnMissingResolver()\n return false\n }\n return (await hostLockedResolver(hostId)) === true\n },\n }\n}\n\nlet warnedMissingResolver = false\nfunction warnMissingResolver(): void {\n if (warnedMissingResolver) return\n warnedMissingResolver = true\n console.error(\n '[plugin-jobs] no host lockdown resolver is registered — background ' +\n 'jobs are running UNGATED. Register one with ' +\n 'registerPluginJobHostLockdown() from the app that owns the runner.',\n )\n}\n\n/** Test seam: let the \"warned once\" latch be re-armed. */\nexport function resetPluginJobLockdownWarningForTests(): void {\n warnedMissingResolver = false\n}\n\n/**\n * What a job touches, and therefore what the runner owes it. REQUIRED on\n * every registration — the point of the field is that a new job cannot\n * compile without its author answering.\n */\nexport type PluginJobLockdown =\n /**\n * The job acts FOR HOSTS. The runner injects a {@link PluginJobHostGate}\n * and the handler must ask it for every host it is about to touch.\n */\n | { scope: 'per-host' }\n /**\n * The job touches nothing a lock could be about. `reason` is the argument,\n * and `lockdown-tenant-api-coverage.spec.ts` holds the set by name so a new\n * one has to be argued rather than declared.\n */\n | { scope: 'platform'; reason: string }\n\nexport interface PluginJob {\n pluginId: string\n /** Stable job name, unique within the plugin ('expire-stale-holds'). */\n name: string\n intervalMinutes: number\n description?: string\n /** See {@link PluginJobLockdown}. Required, deliberately. */\n lockdown: PluginJobLockdown\n handler: (gate: PluginJobHostGate) => Promise<void> | void\n}\n\nconst jobs = new Map<string, PluginJob>()\n\nexport const pluginJobKey = (job: Pick<PluginJob, 'pluginId' | 'name'>) =>\n `${job.pluginId}:${job.name}`\n\n/** Idempotent per pluginId:name. */\nexport function registerPluginJob(job: PluginJob): void {\n jobs.set(pluginJobKey(job), job)\n}\n\nexport function listPluginJobs(): PluginJob[] {\n return [...jobs.values()]\n}\n\nexport interface PluginJobResult {\n key: string\n ok: boolean\n error?: string\n}\n\n/**\n * The gate a `platform`-scoped job gets: one that REFUSES to answer.\n *\n * A declaration of `platform` scope is a claim that there is no host to ask\n * about. If such a job asks anyway, the claim is false and the registration\n * is the thing that needs fixing — so the runner surfaces it as that job's\n * own failure (isolated, like every other job error) instead of quietly\n * answering \"not locked\" and letting a mislabelled job mutate a locked site.\n *\n * This is the one place the contract is enforced at RUNTIME rather than by\n * the type or by the guard, and it is enforceable precisely because asking\n * is the only way to get an answer.\n */\nfunction refusingGate(key: string): PluginJobHostGate {\n return {\n isLocked: async (hostId: string) => {\n throw new Error(\n `plugin job ${key} declared lockdown scope 'platform' but asked ` +\n `about host \"${hostId}\". Declare { scope: 'per-host' } instead.`,\n )\n },\n }\n}\n\n/**\n * Runs the jobs `due` selects (default: all), sequentially with error\n * isolation — one broken job never blocks the rest.\n *\n * Every handler is handed a {@link PluginJobHostGate} (AGL-2495). It is a\n * parameter rather than something the handler imports so that the runner\n * stays the single place the verdict comes from: swap what\n * `registerPluginJobHostLockdown` holds and every job on the beat changes\n * behaviour together, including one written next month.\n */\nexport async function runPluginJobs(\n due?: (job: PluginJob) => boolean,\n): Promise<PluginJobResult[]> {\n const results: PluginJobResult[] = []\n for (const job of jobs.values()) {\n if (due && !due(job)) continue\n const key = pluginJobKey(job)\n try {\n await job.handler(\n job.lockdown?.scope === 'platform'\n ? refusingGate(key)\n : pluginJobHostGate(),\n )\n results.push({ key, ok: true })\n } catch (error) {\n console.error(`plugin job ${key} failed:`, error)\n results.push({ key, ok: false, error: String(error) })\n }\n }\n return results\n}\n"],"names":["hostLockedResolver","registerPluginJobHostLockdown","resolver","hasPluginJobHostLockdown","resetPluginJobHostLockdownForTests","pluginJobHostGate","isLocked","hostId","warnMissingResolver","warnedMissingResolver","console","error","resetPluginJobLockdownWarningForTests","jobs","Map","pluginJobKey","job","pluginId","name","registerPluginJob","set","listPluginJobs","values","refusingGate","key","Error","runPluginJobs","due","results","handler","lockdown","scope","push","ok","String"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;CAOC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2CAwC2C,GAE3C;;;CAGC,GAKD,IAAIA,qBAAyD;AAE7D;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,8BACdC,QAAqC;IAErCF,qBAAqBE;AACvB;AAEA,sEAAsE,GACtE,OAAO,SAASC;IACd,OAAOH,uBAAuB;AAChC;AAEA,wCAAwC,GACxC,OAAO,SAASI;IACdJ,qBAAqB;AACvB;AAmBA;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASK;IACd,OAAO;QACLC,UAAU,OAAOC;YACf,IAAI,CAACA,QAAQ,OAAO;YACpB,IAAI,CAACP,oBAAoB;gBACvBQ;gBACA,OAAO;YACT;YACA,OAAO,AAAC,MAAMR,mBAAmBO,YAAa;QAChD;IACF;AACF;AAEA,IAAIE,wBAAwB;AAC5B,SAASD;IACP,IAAIC,uBAAuB;IAC3BA,wBAAwB;IACxBC,QAAQC,KAAK,CACX,wEACE,iDACA;AAEN;AAEA,wDAAwD,GACxD,OAAO,SAASC;IACdH,wBAAwB;AAC1B;AA+BA,MAAMI,OAAO,IAAIC;AAEjB,OAAO,MAAMC,eAAe,CAACC,MAC3B,GAAGA,IAAIC,QAAQ,CAAC,CAAC,EAAED,IAAIE,IAAI,EAAE,CAAA;AAE/B,kCAAkC,GAClC,OAAO,SAASC,kBAAkBH,GAAc;IAC9CH,KAAKO,GAAG,CAACL,aAAaC,MAAMA;AAC9B;AAEA,OAAO,SAASK;IACd,OAAO;WAAIR,KAAKS,MAAM;KAAG;AAC3B;AAQA;;;;;;;;;;;;CAYC,GACD,SAASC,aAAaC,GAAW;IAC/B,OAAO;QACLlB,UAAU,OAAOC;YACf,MAAM,IAAIkB,MACR,CAAC,WAAW,EAAED,IAAI,8CAA8C,CAAC,GAC/D,CAAC,YAAY,EAAEjB,OAAO,yCAAyC,CAAC;QAEtE;IACF;AACF;AAEA;;;;;;;;;CASC,GACD,OAAO,eAAemB,cACpBC,GAAiC;IAEjC,MAAMC,UAA6B,EAAE;IACrC,KAAK,MAAMZ,OAAOH,KAAKS,MAAM,GAAI;QAC/B,IAAIK,OAAO,CAACA,IAAIX,MAAM;QACtB,MAAMQ,MAAMT,aAAaC;QACzB,IAAI;gBAEAA;YADF,MAAMA,IAAIa,OAAO,CACfb,EAAAA,gBAAAA,IAAIc,QAAQ,qBAAZd,cAAce,KAAK,MAAK,aACpBR,aAAaC,OACbnB;YAENuB,QAAQI,IAAI,CAAC;gBAAER;gBAAKS,IAAI;YAAK;QAC/B,EAAE,OAAOtB,OAAO;YACdD,QAAQC,KAAK,CAAC,CAAC,WAAW,EAAEa,IAAI,QAAQ,CAAC,EAAEb;YAC3CiB,QAAQI,IAAI,CAAC;gBAAER;gBAAKS,IAAI;gBAAOtB,OAAOuB,OAAOvB;YAAO;QACtD;IACF;IACA,OAAOiB;AACT"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-jobs.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Plugin scheduled jobs (AGL-435, Strapi cron parity): `/server` entries\n * register named jobs with an interval; a guarded platform runner route\n * (`/api/plugins/run-jobs`, secret header, invoked by the deployment's\n * scheduler) executes the due ones. The REGISTRY is pure — due-ness and\n * last-run persistence belong to the runner, so core stays storage-free.\n * Handlers must be idempotent and bounded (they share the API process).\n */\n\n/*==========================================\n * THE LOCKDOWN GATE FOR BACKGROUND WORK (AGL-2495, from the AGL-1621 drill).\n *\n * A job runs on PLATFORM credentials, from a secret-gated route, with no\n * visitor, no session and no org — so none of the gates that cover the\n * visitor-facing write paths are anywhere near it. That is exactly the shape\n * the drill found in `publish-schedule-job.ts`: a scheduled publish firing on\n * a locked host. The commerce and bookings beats are the sharper version of\n * the same thing, because they touch orders, stock and money for a host that\n * may be suspended for abuse, non-payment or a legal takedown.\n *\n * ## Why the gate lives HERE and not at the six call sites\n *\n * Six call-site edits close six holes and guarantee the seventh is forgotten.\n * Gating in the job contract means the seventh job is covered the day it is\n * written: `lockdown` below is REQUIRED, so a new `registerPluginJob({…})`\n * does not compile until its author has answered \"what does this touch\".\n *\n * (The nx boundary is NOT why. `libs/plugins/*` CAN import\n * `@aglyn/tenant-data-admin` — it carries `scope:data` and `scope:aglyn`,\n * both on the `aglyn:addons` allowlist, and 196 files in `libs/plugins`\n * already do. The AGL-2495 sweep recorded the opposite as its reason for\n * deferring; it was wrong about the constraint and right about the design.)\n *\n * ## Why core cannot just call `getSiteLockdown` itself\n *\n * `@aglyn/tenant-data-admin` imports `@aglyn/aglyn/server`, so a core import\n * of it is a project CYCLE. Hence the same registry shape the platform uses\n * for every other app↔core edge (`registerBillingWebhookHandler`,\n * `registerSitePageResolver`): the host app\n * registers the resolver from a place that may import the admin lib, and core\n * looks it up. Nothing is imported statically in either direction and a\n * self-host build with a different carrier registers its own.\n *\n * ## Fail-open is INHERITED, not re-decided\n *\n * The gate asks whatever the app registered — on the tenant that is\n * `getSiteLockdown`, which owns the fail-open/fail-closed decision (and the\n * takedown-class ratchet on top of it). This module deliberately holds no\n * parallel notion of what a lock is: it has one question and no opinion.\n *=========================================*/\n\n/**\n * The one question a beat asks before touching a host: is a lock active that\n * forbids this write? Resolved by the host app, never by core.\n */\nexport type PluginJobHostLockedResolver = (\n hostId: string,\n) => Promise<boolean> | boolean\n\nlet hostLockedResolver: PluginJobHostLockedResolver | null = null\n\n/**\n * Registers the platform's per-host lockdown verdict for background work,\n * from a place that may import the admin data lib — on this platform,\n * `apps/tenant/utils/plugin-job-lockdown.ts`, loaded for its side effect by\n * the runner route beside the core job registrations.\n *\n * Idempotent and last-registration-wins, unlike a registry that holds a\n * capability with a plugin OWNER, where a second claimant is a\n * misconfiguration. This is infrastructure the deployment supplies exactly\n * once, and a process that loads the module twice must not throw on the\n * second pass.\n */\nexport function registerPluginJobHostLockdown(\n resolver: PluginJobHostLockedResolver,\n): void {\n hostLockedResolver = resolver\n}\n\n/** Has the deployment wired a verdict? Reported by the runner route. */\nexport function hasPluginJobHostLockdown(): boolean {\n return hostLockedResolver !== null\n}\n\n/** Test seam: forget the registration. */\nexport function resetPluginJobHostLockdownForTests(): void {\n hostLockedResolver = null\n}\n\n/**\n * The gate handed to a `per-host` job. One method, so there is exactly one\n * thing to call and one thing for the coverage guard to look for:\n *\n * if (await gate.isLocked(hostId)) continue\n *\n * SKIPPED, NOT DROPPED is the caller's obligation and the reason this\n * answers a question rather than performing the skip: `continue` leaves the\n * row exactly as it was, so the work lands on the next beat after the lift.\n * A gate that swallowed the row — stamping it done, deleting it, advancing a\n * cursor past it — would turn a pause into a cancellation, which is what\n * `publish-schedule-job.ts` argued at length and what every job here copies.\n */\nexport interface PluginJobHostGate {\n isLocked(hostId: string): Promise<boolean>\n}\n\n/**\n * The gate as the runner builds it.\n *\n * The MANUAL doors — `commerce/process-abandoned`, `commerce/process-restock`,\n * `bookings/reminders` — do NOT use it (AGL-3356). They are served by the\n * console, which registers no resolver, so this answered \"not locked\" for\n * every site there and a forced pass mailed a suspended site's customers.\n * They take `siteLockdownJobGate()` from the admin lib instead: the same\n * function the tenant registers here, needing no registration at all.\n *\n * NO resolver registered answers \"not locked\", loudly and once. That is the\n * fail-open direction on purpose: a deployment that has not wired a carrier\n * (a self-host, a test process) must not have every background job welded\n * shut by infrastructure it never asked for. The loss is real and is why\n * `hasPluginJobHostLockdown` is reported by the runner route rather than\n * left to be inferred from nothing happening.\n *\n * ONE VERDICT PER HOST PER GATE (AGL-4121). The resolver's answer costs three\n * Firestore reads (org id, org, host) and outside a request `React.cache`\n * dedupes none of them, so a scan that asked once per row paid three reads per\n * row for one site's answer. The runner mints a gate per job per run, so a\n * verdict held here lives exactly as long as the run that asked, the way the\n * sending-identity and branding maps in those scans do, and the next beat asks\n * afresh. A lock laid mid-run therefore reaches a site already asked about on\n * the next beat rather than on that run's next row.\n *\n * The promise is what is held, so askers running side by side\n * (`pos-cellular-billing` asks under a `Promise.all`) share one read. A\n * resolver that failed gave no verdict, so nothing is kept and the next row\n * asks again, as it always has.\n */\nexport function pluginJobHostGate(): PluginJobHostGate {\n const verdicts = new Map<string, Promise<boolean>>()\n const ask = async (hostId: string): Promise<boolean> => {\n if (!hostId) return false\n if (!hostLockedResolver) {\n warnMissingResolver()\n return false\n }\n return (await hostLockedResolver(hostId)) === true\n }\n return {\n isLocked: (hostId: string) => {\n const held = verdicts.get(hostId)\n if (held) return held\n const verdict = ask(hostId)\n verdicts.set(hostId, verdict)\n verdict.catch(() => verdicts.delete(hostId))\n return verdict\n },\n }\n}\n\nlet warnedMissingResolver = false\nfunction warnMissingResolver(): void {\n if (warnedMissingResolver) return\n warnedMissingResolver = true\n console.error(\n '[plugin-jobs] no host lockdown resolver is registered — background ' +\n 'jobs are running UNGATED. Register one with ' +\n 'registerPluginJobHostLockdown() from the app that owns the runner.',\n )\n}\n\n/** Test seam: let the \"warned once\" latch be re-armed. */\nexport function resetPluginJobLockdownWarningForTests(): void {\n warnedMissingResolver = false\n}\n\n/**\n * What a job touches, and therefore what the runner owes it. REQUIRED on\n * every registration — the point of the field is that a new job cannot\n * compile without its author answering.\n */\nexport type PluginJobLockdown =\n /**\n * The job acts FOR HOSTS. The runner injects a {@link PluginJobHostGate}\n * and the handler must ask it for every host it is about to touch.\n */\n | { scope: 'per-host' }\n /**\n * The job touches nothing a lock could be about. `reason` is the argument,\n * and `lockdown-tenant-api-coverage.spec.ts` holds the set by name so a new\n * one has to be argued rather than declared.\n */\n | { scope: 'platform'; reason: string }\n\nexport interface PluginJob {\n pluginId: string\n /** Stable job name, unique within the plugin ('expire-stale-holds'). */\n name: string\n intervalMinutes: number\n description?: string\n /** See {@link PluginJobLockdown}. Required, deliberately. */\n lockdown: PluginJobLockdown\n handler: (gate: PluginJobHostGate) => Promise<void> | void\n}\n\nconst jobs = new Map<string, PluginJob>()\n\nexport const pluginJobKey = (job: Pick<PluginJob, 'pluginId' | 'name'>) =>\n `${job.pluginId}:${job.name}`\n\n/** Idempotent per pluginId:name. */\nexport function registerPluginJob(job: PluginJob): void {\n jobs.set(pluginJobKey(job), job)\n}\n\nexport function listPluginJobs(): PluginJob[] {\n return [...jobs.values()]\n}\n\nexport interface PluginJobResult {\n key: string\n ok: boolean\n error?: string\n}\n\n/**\n * The gate a `platform`-scoped job gets: one that REFUSES to answer.\n *\n * A declaration of `platform` scope is a claim that there is no host to ask\n * about. If such a job asks anyway, the claim is false and the registration\n * is the thing that needs fixing — so the runner surfaces it as that job's\n * own failure (isolated, like every other job error) instead of quietly\n * answering \"not locked\" and letting a mislabelled job mutate a locked site.\n *\n * This is the one place the contract is enforced at RUNTIME rather than by\n * the type or by the guard, and it is enforceable precisely because asking\n * is the only way to get an answer.\n */\nfunction refusingGate(key: string): PluginJobHostGate {\n return {\n isLocked: async (hostId: string) => {\n throw new Error(\n `plugin job ${key} declared lockdown scope 'platform' but asked ` +\n `about host \"${hostId}\". Declare { scope: 'per-host' } instead.`,\n )\n },\n }\n}\n\n/**\n * Runs the jobs `due` selects (default: all), sequentially with error\n * isolation — one broken job never blocks the rest.\n *\n * Every handler is handed a {@link PluginJobHostGate} (AGL-2495). It is a\n * parameter rather than something the handler imports so that the runner\n * stays the single place the verdict comes from: swap what\n * `registerPluginJobHostLockdown` holds and every job on the beat changes\n * behaviour together, including one written next month.\n */\nexport async function runPluginJobs(\n due?: (job: PluginJob) => boolean,\n): Promise<PluginJobResult[]> {\n const results: PluginJobResult[] = []\n for (const job of jobs.values()) {\n if (due && !due(job)) continue\n const key = pluginJobKey(job)\n try {\n await job.handler(\n job.lockdown?.scope === 'platform'\n ? refusingGate(key)\n : pluginJobHostGate(),\n )\n results.push({ key, ok: true })\n } catch (error) {\n console.error(`plugin job ${key} failed:`, error)\n results.push({ key, ok: false, error: String(error) })\n }\n }\n return results\n}\n"],"names":["hostLockedResolver","registerPluginJobHostLockdown","resolver","hasPluginJobHostLockdown","resetPluginJobHostLockdownForTests","pluginJobHostGate","verdicts","Map","ask","hostId","warnMissingResolver","isLocked","held","get","verdict","set","catch","delete","warnedMissingResolver","console","error","resetPluginJobLockdownWarningForTests","jobs","pluginJobKey","job","pluginId","name","registerPluginJob","listPluginJobs","values","refusingGate","key","Error","runPluginJobs","due","results","handler","lockdown","scope","push","ok","String"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;CAOC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2CAwC2C,GAE3C;;;CAGC,GAKD,IAAIA,qBAAyD;AAE7D;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,8BACdC,QAAqC;IAErCF,qBAAqBE;AACvB;AAEA,sEAAsE,GACtE,OAAO,SAASC;IACd,OAAOH,uBAAuB;AAChC;AAEA,wCAAwC,GACxC,OAAO,SAASI;IACdJ,qBAAqB;AACvB;AAmBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BC,GACD,OAAO,SAASK;IACd,MAAMC,WAAW,IAAIC;IACrB,MAAMC,MAAM,OAAOC;QACjB,IAAI,CAACA,QAAQ,OAAO;QACpB,IAAI,CAACT,oBAAoB;YACvBU;YACA,OAAO;QACT;QACA,OAAO,AAAC,MAAMV,mBAAmBS,YAAa;IAChD;IACA,OAAO;QACLE,UAAU,CAACF;YACT,MAAMG,OAAON,SAASO,GAAG,CAACJ;YAC1B,IAAIG,MAAM,OAAOA;YACjB,MAAME,UAAUN,IAAIC;YACpBH,SAASS,GAAG,CAACN,QAAQK;YACrBA,QAAQE,KAAK,CAAC,IAAMV,SAASW,MAAM,CAACR;YACpC,OAAOK;QACT;IACF;AACF;AAEA,IAAII,wBAAwB;AAC5B,SAASR;IACP,IAAIQ,uBAAuB;IAC3BA,wBAAwB;IACxBC,QAAQC,KAAK,CACX,wEACE,iDACA;AAEN;AAEA,wDAAwD,GACxD,OAAO,SAASC;IACdH,wBAAwB;AAC1B;AA+BA,MAAMI,OAAO,IAAIf;AAEjB,OAAO,MAAMgB,eAAe,CAACC,MAC3B,GAAGA,IAAIC,QAAQ,CAAC,CAAC,EAAED,IAAIE,IAAI,EAAE,CAAA;AAE/B,kCAAkC,GAClC,OAAO,SAASC,kBAAkBH,GAAc;IAC9CF,KAAKP,GAAG,CAACQ,aAAaC,MAAMA;AAC9B;AAEA,OAAO,SAASI;IACd,OAAO;WAAIN,KAAKO,MAAM;KAAG;AAC3B;AAQA;;;;;;;;;;;;CAYC,GACD,SAASC,aAAaC,GAAW;IAC/B,OAAO;QACLpB,UAAU,OAAOF;YACf,MAAM,IAAIuB,MACR,CAAC,WAAW,EAAED,IAAI,8CAA8C,CAAC,GAC/D,CAAC,YAAY,EAAEtB,OAAO,yCAAyC,CAAC;QAEtE;IACF;AACF;AAEA;;;;;;;;;CASC,GACD,OAAO,eAAewB,cACpBC,GAAiC;IAEjC,MAAMC,UAA6B,EAAE;IACrC,KAAK,MAAMX,OAAOF,KAAKO,MAAM,GAAI;QAC/B,IAAIK,OAAO,CAACA,IAAIV,MAAM;QACtB,MAAMO,MAAMR,aAAaC;QACzB,IAAI;gBAEAA;YADF,MAAMA,IAAIY,OAAO,CACfZ,EAAAA,gBAAAA,IAAIa,QAAQ,qBAAZb,cAAcc,KAAK,MAAK,aACpBR,aAAaC,OACb1B;YAEN8B,QAAQI,IAAI,CAAC;gBAAER;gBAAKS,IAAI;YAAK;QAC/B,EAAE,OAAOpB,OAAO;YACdD,QAAQC,KAAK,CAAC,CAAC,WAAW,EAAEW,IAAI,QAAQ,CAAC,EAAEX;YAC3Ce,QAAQI,IAAI,CAAC;gBAAER;gBAAKS,IAAI;gBAAOpB,OAAOqB,OAAOrB;YAAO;QACtD;IACF;IACA,OAAOe;AACT"}