@aglyn/tenant-data-admin 1.0.0-beta.261 → 1.0.0-beta.263

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.
Files changed (34) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/figure-readers.d.ts +44 -0
  3. package/src/lib/server/figure-readers.js +45 -0
  4. package/src/lib/server/figure-readers.js.map +1 -0
  5. package/src/lib/server/keep-alive.d.ts +18 -0
  6. package/src/lib/server/keep-alive.js +59 -0
  7. package/src/lib/server/keep-alive.js.map +1 -0
  8. package/src/lib/server/mobile-push-switch.js +17 -4
  9. package/src/lib/server/mobile-push-switch.js.map +1 -1
  10. package/src/lib/server/native-push.js +6 -3
  11. package/src/lib/server/native-push.js.map +1 -1
  12. package/src/lib/server/notifications.d.ts +16 -5
  13. package/src/lib/server/notifications.js +30 -9
  14. package/src/lib/server/notifications.js.map +1 -1
  15. package/src/lib/server/operator-alerts.d.ts +7 -0
  16. package/src/lib/server/operator-alerts.js +6 -3
  17. package/src/lib/server/operator-alerts.js.map +1 -1
  18. package/src/lib/server/operator-health.d.ts +69 -0
  19. package/src/lib/server/operator-health.js +87 -0
  20. package/src/lib/server/operator-health.js.map +1 -1
  21. package/src/lib/server/push-apns.js +3 -1
  22. package/src/lib/server/push-apns.js.map +1 -1
  23. package/src/lib/server/push-delivery.d.ts +9 -0
  24. package/src/lib/server/push-delivery.js +10 -0
  25. package/src/lib/server/push-delivery.js.map +1 -1
  26. package/src/lib/server/render-system-email.d.ts +7 -1
  27. package/src/lib/server/render-system-email.js +1 -1
  28. package/src/lib/server/render-system-email.js.map +1 -1
  29. package/src/lib/server/sso-domain-policy.d.ts +43 -7
  30. package/src/lib/server/sso-domain-policy.js +61 -9
  31. package/src/lib/server/sso-domain-policy.js.map +1 -1
  32. package/src/lib/server/weekly-summary.d.ts +160 -0
  33. package/src/lib/server/weekly-summary.js +568 -0
  34. package/src/lib/server/weekly-summary.js.map +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/tenant-data-admin",
3
- "version": "1.0.0-beta.261",
3
+ "version": "1.0.0-beta.263",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,12 +25,12 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.261",
29
- "@aglyn/shared-util-email": "1.0.0-beta.261",
30
- "@aglyn/shared-util-fbserver": "1.0.0-beta.261",
31
- "@aglyn/shared-util-first-touch": "1.0.0-beta.261",
32
- "@aglyn/shared-util-http": "1.0.0-beta.261",
33
- "@aglyn/shared-util-tools": "1.0.0-beta.261",
28
+ "@aglyn/aglyn": "1.0.0-beta.263",
29
+ "@aglyn/shared-util-email": "1.0.0-beta.263",
30
+ "@aglyn/shared-util-fbserver": "1.0.0-beta.263",
31
+ "@aglyn/shared-util-first-touch": "1.0.0-beta.263",
32
+ "@aglyn/shared-util-http": "1.0.0-beta.263",
33
+ "@aglyn/shared-util-tools": "1.0.0-beta.263",
34
34
  "@msgpack/msgpack": "^3.1.3",
35
35
  "@swc/helpers": "0.5.23",
36
36
  "sharp": "^0.35.5",
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type PluginFigureReader, type ResolvedPluginFigureReader } from '@aglyn/aglyn/plugin-manager/plugin-figures';
18
+ /**
19
+ * The figure readers a caller may read for one workspace or one site
20
+ * (AGL-2915): the caller's half of the `plugin-figures` contract.
21
+ *
22
+ * The registry carries no authorization, so before a reader is read this
23
+ * establishes that the workspace's plan includes the feature the figures are
24
+ * sold under, and that the plugin whose records they are runs here: past its
25
+ * release flag, on for the workspace and not switched off for the site. The
26
+ * site belonging to the workspace, and the person's reach, are the caller's
27
+ * own checks.
28
+ */
29
+ /** Which of a set of plugins are past their release flags for an org; the platform's flags by default. */
30
+ export type ReleasedFigurePlugins = (pluginIds: string[], orgId: string) => Promise<string[]>;
31
+ export interface FigureReaderContext {
32
+ orgId: string;
33
+ /** The site the figures are about; `null` for the workspace as a whole. */
34
+ hostId: string | null;
35
+ /** The org document. */
36
+ org: object | null;
37
+ /** The site's host document, already checked to belong to the org; `null` with no site. */
38
+ host: Record<string, unknown> | null;
39
+ /** Which readers the caller asks about at all, before the gates. */
40
+ accept: (reader: PluginFigureReader) => boolean;
41
+ released?: ReleasedFigurePlugins;
42
+ }
43
+ /** The readers that pass the gates, ordered by id so a list of them is the same on every call. */
44
+ export declare function eligibleFigureReaders(context: FigureReaderContext): Promise<ResolvedPluginFigureReader[]>;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { checkEntitlement } from "@aglyn/aglyn/app-utils/plan-entitlements";
17
+ import { isHostPluginEnabled, isPluginEnabled } from "@aglyn/aglyn/plugin-manager/enabled-plugins";
18
+ import { listPluginFigureReaders, pluginFigureReaderPlugin } from "@aglyn/aglyn/plugin-manager/plugin-figures";
19
+ import { filterEnabledPluginsByReleaseFlags } from "./release-flags.js";
20
+ const releasedByFlags = (pluginIds, orgId)=>filterEnabledPluginsByReleaseFlags(pluginIds, {
21
+ orgId,
22
+ authorization: null
23
+ });
24
+ /** The readers that pass the gates, ordered by id so a list of them is the same on every call. */ export async function eligibleFigureReaders(context) {
25
+ var _context_released;
26
+ const org = context.org;
27
+ const candidates = listPluginFigureReaders().filter(({ reader })=>{
28
+ if (!context.accept(reader)) return false;
29
+ // A site reader needs a site; a workspace reader reads with or without one.
30
+ if (reader.scope === 'site' && !context.hostId) return false;
31
+ return !reader.feature || checkEntitlement(org, reader.feature);
32
+ });
33
+ const plugins = [
34
+ ...new Set(candidates.map(pluginFigureReaderPlugin).filter((id)=>Boolean(id)))
35
+ ];
36
+ const released = new Set(plugins.length ? await ((_context_released = context.released) != null ? _context_released : releasedByFlags)(plugins, context.orgId) : []);
37
+ return candidates.filter((resolved)=>{
38
+ const plugin = pluginFigureReaderPlugin(resolved);
39
+ if (!plugin) return true;
40
+ if (!released.has(plugin)) return false;
41
+ return context.hostId ? isHostPluginEnabled(org, context.host, plugin) : isPluginEnabled(org, plugin);
42
+ });
43
+ }
44
+
45
+ //# sourceMappingURL=figure-readers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/figure-readers.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\nimport { checkEntitlement } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { isHostPluginEnabled, isPluginEnabled } from '@aglyn/aglyn/plugin-manager/enabled-plugins'\nimport {\n listPluginFigureReaders,\n pluginFigureReaderPlugin,\n type PluginFigureReader,\n type ResolvedPluginFigureReader,\n} from '@aglyn/aglyn/plugin-manager/plugin-figures'\nimport { filterEnabledPluginsByReleaseFlags } from './release-flags'\n\n/**\n * The figure readers a caller may read for one workspace or one site\n * (AGL-2915): the caller's half of the `plugin-figures` contract.\n *\n * The registry carries no authorization, so before a reader is read this\n * establishes that the workspace's plan includes the feature the figures are\n * sold under, and that the plugin whose records they are runs here: past its\n * release flag, on for the workspace and not switched off for the site. The\n * site belonging to the workspace, and the person's reach, are the caller's\n * own checks.\n */\n\n/** Which of a set of plugins are past their release flags for an org; the platform's flags by default. */\nexport type ReleasedFigurePlugins = (pluginIds: string[], orgId: string) => Promise<string[]>\n\nconst releasedByFlags: ReleasedFigurePlugins = (pluginIds, orgId) =>\n filterEnabledPluginsByReleaseFlags(pluginIds, { orgId, authorization: null })\n\nexport interface FigureReaderContext {\n orgId: string\n /** The site the figures are about; `null` for the workspace as a whole. */\n hostId: string | null\n /** The org document. */\n org: object | null\n /** The site's host document, already checked to belong to the org; `null` with no site. */\n host: Record<string, unknown> | null\n /** Which readers the caller asks about at all, before the gates. */\n accept: (reader: PluginFigureReader) => boolean\n released?: ReleasedFigurePlugins\n}\n\n/** The readers that pass the gates, ordered by id so a list of them is the same on every call. */\nexport async function eligibleFigureReaders(\n context: FigureReaderContext,\n): Promise<ResolvedPluginFigureReader[]> {\n const org = context.org as { enabledPlugins?: string[] } | null\n const candidates = listPluginFigureReaders().filter(({ reader }) => {\n if (!context.accept(reader)) return false\n // A site reader needs a site; a workspace reader reads with or without one.\n if (reader.scope === 'site' && !context.hostId) return false\n return !reader.feature || checkEntitlement(org as never, reader.feature)\n })\n const plugins = [\n ...new Set(candidates.map(pluginFigureReaderPlugin).filter((id): id is string => Boolean(id))),\n ]\n const released = new Set(\n plugins.length ? await (context.released ?? releasedByFlags)(plugins, context.orgId) : [],\n )\n return candidates.filter((resolved) => {\n const plugin = pluginFigureReaderPlugin(resolved)\n if (!plugin) return true\n if (!released.has(plugin)) return false\n return context.hostId\n ? isHostPluginEnabled(org, context.host as { disabledPlugins?: string[] } | null, plugin)\n : isPluginEnabled(org, plugin)\n })\n}\n"],"names":["checkEntitlement","isHostPluginEnabled","isPluginEnabled","listPluginFigureReaders","pluginFigureReaderPlugin","filterEnabledPluginsByReleaseFlags","releasedByFlags","pluginIds","orgId","authorization","eligibleFigureReaders","context","org","candidates","filter","reader","accept","scope","hostId","feature","plugins","Set","map","id","Boolean","released","length","resolved","plugin","has","host"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,gBAAgB,QAAQ,2CAA0C;AAC3E,SAASC,mBAAmB,EAAEC,eAAe,QAAQ,8CAA6C;AAClG,SACEC,uBAAuB,EACvBC,wBAAwB,QAGnB,6CAA4C;AACnD,SAASC,kCAAkC,QAAQ,qBAAiB;AAiBpE,MAAMC,kBAAyC,CAACC,WAAWC,QACzDH,mCAAmCE,WAAW;QAAEC;QAAOC,eAAe;IAAK;AAe7E,gGAAgG,GAChG,OAAO,eAAeC,sBACpBC,OAA4B;QAaFA;IAX1B,MAAMC,MAAMD,QAAQC,GAAG;IACvB,MAAMC,aAAaV,0BAA0BW,MAAM,CAAC,CAAC,EAAEC,MAAM,EAAE;QAC7D,IAAI,CAACJ,QAAQK,MAAM,CAACD,SAAS,OAAO;QACpC,4EAA4E;QAC5E,IAAIA,OAAOE,KAAK,KAAK,UAAU,CAACN,QAAQO,MAAM,EAAE,OAAO;QACvD,OAAO,CAACH,OAAOI,OAAO,IAAInB,iBAAiBY,KAAcG,OAAOI,OAAO;IACzE;IACA,MAAMC,UAAU;WACX,IAAIC,IAAIR,WAAWS,GAAG,CAAClB,0BAA0BU,MAAM,CAAC,CAACS,KAAqBC,QAAQD;KAC1F;IACD,MAAME,WAAW,IAAIJ,IACnBD,QAAQM,MAAM,GAAG,MAAM,EAACf,oBAAAA,QAAQc,QAAQ,YAAhBd,oBAAoBL,iBAAiBc,SAAST,QAAQH,KAAK,IAAI,EAAE;IAE3F,OAAOK,WAAWC,MAAM,CAAC,CAACa;QACxB,MAAMC,SAASxB,yBAAyBuB;QACxC,IAAI,CAACC,QAAQ,OAAO;QACpB,IAAI,CAACH,SAASI,GAAG,CAACD,SAAS,OAAO;QAClC,OAAOjB,QAAQO,MAAM,GACjBjB,oBAAoBW,KAAKD,QAAQmB,IAAI,EAA2CF,UAChF1B,gBAAgBU,KAAKgB;IAC3B;AACF"}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /** Returns `work` unchanged, after asking the platform to wait for it. */
18
+ export declare function keepAlive<T>(work: Promise<T>): Promise<T>;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * Keeps fire-and-forget work alive after the response is sent.
18
+ *
19
+ * A serverless function may be frozen the moment its handler returns, and a
20
+ * notification is usually started with `void notify…()` beside the mutation
21
+ * it reports. The feed write lands first, so the console shows it, while the
22
+ * push and the email after it are cut off part way: the native apps then miss
23
+ * notifications the browser shows. Handing the promise to the platform's
24
+ * `waitUntil` keeps the invocation alive until it settles.
25
+ *
26
+ * Next reads `waitUntil` from the `@next/request-context` slot, and Vercel's
27
+ * own helpers read `@vercel/request-context`; both are asked. Outside a
28
+ * request (a script, a test, a cron worker that awaits anyway) there is no
29
+ * context and the work simply runs as it would have.
30
+ */ const CONTEXT_SLOTS = [
31
+ Symbol.for('@next/request-context'),
32
+ Symbol.for('@vercel/request-context')
33
+ ];
34
+ function platformWaitUntil() {
35
+ const scope = globalThis;
36
+ for (const slot of CONTEXT_SLOTS){
37
+ try {
38
+ var _scope_slot_get, _scope_slot_get1, _scope_slot;
39
+ const waitUntil = (_scope_slot = scope[slot]) == null ? void 0 : (_scope_slot_get1 = _scope_slot.get) == null ? void 0 : (_scope_slot_get = _scope_slot_get1.call(_scope_slot)) == null ? void 0 : _scope_slot_get.waitUntil;
40
+ if (typeof waitUntil === 'function') return waitUntil;
41
+ } catch (unused) {
42
+ // A slot that cannot answer is treated as absent.
43
+ }
44
+ }
45
+ return undefined;
46
+ }
47
+ /** Returns `work` unchanged, after asking the platform to wait for it. */ export function keepAlive(work) {
48
+ const waitUntil = platformWaitUntil();
49
+ if (waitUntil) {
50
+ try {
51
+ waitUntil(work.catch(()=>undefined));
52
+ } catch (unused) {
53
+ // The platform refused the promise: the work still runs.
54
+ }
55
+ }
56
+ return work;
57
+ }
58
+
59
+ //# sourceMappingURL=keep-alive.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/keep-alive.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 * Keeps fire-and-forget work alive after the response is sent.\n *\n * A serverless function may be frozen the moment its handler returns, and a\n * notification is usually started with `void notify…()` beside the mutation\n * it reports. The feed write lands first, so the console shows it, while the\n * push and the email after it are cut off part way: the native apps then miss\n * notifications the browser shows. Handing the promise to the platform's\n * `waitUntil` keeps the invocation alive until it settles.\n *\n * Next reads `waitUntil` from the `@next/request-context` slot, and Vercel's\n * own helpers read `@vercel/request-context`; both are asked. Outside a\n * request (a script, a test, a cron worker that awaits anyway) there is no\n * context and the work simply runs as it would have.\n */\n\ntype WaitUntil = (promise: Promise<unknown>) => void\n\ninterface RequestContextSlot {\n get?: () => { waitUntil?: WaitUntil } | undefined\n}\n\nconst CONTEXT_SLOTS = [Symbol.for('@next/request-context'), Symbol.for('@vercel/request-context')]\n\nfunction platformWaitUntil(): WaitUntil | undefined {\n const scope = globalThis as unknown as Record<symbol, RequestContextSlot | undefined>\n for (const slot of CONTEXT_SLOTS) {\n try {\n const waitUntil = scope[slot]?.get?.()?.waitUntil\n if (typeof waitUntil === 'function') return waitUntil\n } catch {\n // A slot that cannot answer is treated as absent.\n }\n }\n return undefined\n}\n\n/** Returns `work` unchanged, after asking the platform to wait for it. */\nexport function keepAlive<T>(work: Promise<T>): Promise<T> {\n const waitUntil = platformWaitUntil()\n if (waitUntil) {\n try {\n waitUntil(work.catch(() => undefined))\n } catch {\n // The platform refused the promise: the work still runs.\n }\n }\n return work\n}\n"],"names":["CONTEXT_SLOTS","Symbol","for","platformWaitUntil","scope","globalThis","slot","waitUntil","get","undefined","keepAlive","work","catch"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;CAcC,GAQD,MAAMA,gBAAgB;IAACC,OAAOC,GAAG,CAAC;IAA0BD,OAAOC,GAAG,CAAC;CAA2B;AAElG,SAASC;IACP,MAAMC,QAAQC;IACd,KAAK,MAAMC,QAAQN,cAAe;QAChC,IAAI;gBACgBI,iBAAAA,kBAAAA;YAAlB,MAAMG,aAAYH,cAAAA,KAAK,CAACE,KAAK,sBAAXF,mBAAAA,YAAaI,GAAG,sBAAhBJ,kBAAAA,sBAAAA,iCAAAA,gBAAsBG,SAAS;YACjD,IAAI,OAAOA,cAAc,YAAY,OAAOA;QAC9C,EAAE,eAAM;QACN,kDAAkD;QACpD;IACF;IACA,OAAOE;AACT;AAEA,wEAAwE,GACxE,OAAO,SAASC,UAAaC,IAAgB;IAC3C,MAAMJ,YAAYJ;IAClB,IAAII,WAAW;QACb,IAAI;YACFA,UAAUI,KAAKC,KAAK,CAAC,IAAMH;QAC7B,EAAE,eAAM;QACN,yDAAyD;QAC3D;IACF;IACA,OAAOE;AACT"}
@@ -24,21 +24,34 @@
24
24
  var _env_MOBILE_PUSH_ENABLED;
25
25
  return ((_env_MOBILE_PUSH_ENABLED = env['MOBILE_PUSH_ENABLED']) != null ? _env_MOBILE_PUSH_ENABLED : '').trim() !== '0';
26
26
  }
27
- const senders = new Set();
27
+ /*
28
+ * The registry lives on `globalThis`, not in this module: `instrumentation.ts`
29
+ * registers the senders in its own copy of this module, and a route handler's
30
+ * `notifyUsers` reads another one (AGL-3412). A module-scoped set is empty
31
+ * where it is read, and every push is silently skipped.
32
+ */ const SENDERS_KEY = Symbol.for('@aglyn/tenant-data-admin:mobile-push-senders');
33
+ const globalScope = globalThis;
34
+ function senders() {
35
+ const existing = globalScope[SENDERS_KEY];
36
+ if (existing) return existing;
37
+ const created = new Set();
38
+ globalScope[SENDERS_KEY] = created;
39
+ return created;
40
+ }
28
41
  /**
29
42
  * Adds a push transport to the fan-out. `notifyUsers` hands every registered
30
43
  * sender the recipients whose preferences say push, after the feed entries
31
44
  * are committed and only while the kill switch is up. With none registered,
32
45
  * nothing is pushed. Returns the unregister function.
33
46
  */ export function registerMobilePushSender(sender) {
34
- senders.add(sender);
47
+ senders().add(sender);
35
48
  return ()=>{
36
- senders.delete(sender);
49
+ senders().delete(sender);
37
50
  };
38
51
  }
39
52
  /** The registered push transports, in registration order. */ export function mobilePushSenders() {
40
53
  return [
41
- ...senders
54
+ ...senders()
42
55
  ];
43
56
  }
44
57
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/mobile-push-switch.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\nimport type { AglynNotification } from '@aglyn/aglyn/server'\nimport type { Firestore } from 'firebase-admin/firestore'\n\n/**\n * Whether this deployment sends mobile push (AGL-3648).\n *\n * On unless `MOBILE_PUSH_ENABLED=0`: the variable is a kill switch, not an\n * opt-in, and it governs every push sender. Kept apart from the senders so\n * `notifyUsers` can ask before it calls one, and every caller reads the same\n * answer.\n */\nexport function mobilePushEnabled(env: Record<string, string | undefined> = process.env): boolean {\n return (env['MOBILE_PUSH_ENABLED'] ?? '').trim() !== '0'\n}\n\n/** The notification a push sender is handed, as the fan-out wrote it. */\nexport type MobilePushPayload = Omit<AglynNotification, '$id' | 'createdAt' | 'readAt'>\n\n/**\n * A push transport (APNs, FCM): sends one notification to the registered\n * devices (`users/{uid}/devices`) of `uids`. Must not throw on a delivery\n * failure; the console notification is the record and a push is a courtesy.\n */\nexport type MobilePushSender = (\n uids: readonly string[],\n payload: MobilePushPayload,\n context: { db: Firestore },\n) => Promise<void>\n\nconst senders = new Set<MobilePushSender>()\n\n/**\n * Adds a push transport to the fan-out. `notifyUsers` hands every registered\n * sender the recipients whose preferences say push, after the feed entries\n * are committed and only while the kill switch is up. With none registered,\n * nothing is pushed. Returns the unregister function.\n */\nexport function registerMobilePushSender(sender: MobilePushSender): () => void {\n senders.add(sender)\n return () => {\n senders.delete(sender)\n }\n}\n\n/** The registered push transports, in registration order. */\nexport function mobilePushSenders(): MobilePushSender[] {\n return [...senders]\n}\n"],"names":["mobilePushEnabled","env","process","trim","senders","Set","registerMobilePushSender","sender","add","delete","mobilePushSenders"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAKD;;;;;;;CAOC,GACD,OAAO,SAASA,kBAAkBC,MAA0CC,QAAQD,GAAG;QAC7EA;IAAR,OAAO,EAACA,2BAAAA,GAAG,CAAC,sBAAsB,YAA1BA,2BAA8B,IAAIE,IAAI,OAAO;AACvD;AAgBA,MAAMC,UAAU,IAAIC;AAEpB;;;;;CAKC,GACD,OAAO,SAASC,yBAAyBC,MAAwB;IAC/DH,QAAQI,GAAG,CAACD;IACZ,OAAO;QACLH,QAAQK,MAAM,CAACF;IACjB;AACF;AAEA,2DAA2D,GAC3D,OAAO,SAASG;IACd,OAAO;WAAIN;KAAQ;AACrB"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/mobile-push-switch.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\nimport type { AglynNotification } from '@aglyn/aglyn/server'\nimport type { Firestore } from 'firebase-admin/firestore'\n\n/**\n * Whether this deployment sends mobile push (AGL-3648).\n *\n * On unless `MOBILE_PUSH_ENABLED=0`: the variable is a kill switch, not an\n * opt-in, and it governs every push sender. Kept apart from the senders so\n * `notifyUsers` can ask before it calls one, and every caller reads the same\n * answer.\n */\nexport function mobilePushEnabled(env: Record<string, string | undefined> = process.env): boolean {\n return (env['MOBILE_PUSH_ENABLED'] ?? '').trim() !== '0'\n}\n\n/** The notification a push sender is handed, as the fan-out wrote it. */\nexport type MobilePushPayload = Omit<AglynNotification, '$id' | 'createdAt' | 'readAt'>\n\n/**\n * A push transport (APNs, FCM): sends one notification to the registered\n * devices (`users/{uid}/devices`) of `uids`. Must not throw on a delivery\n * failure; the console notification is the record and a push is a courtesy.\n */\nexport type MobilePushSender = (\n uids: readonly string[],\n payload: MobilePushPayload,\n context: { db: Firestore },\n) => Promise<void>\n\n/*\n * The registry lives on `globalThis`, not in this module: `instrumentation.ts`\n * registers the senders in its own copy of this module, and a route handler's\n * `notifyUsers` reads another one (AGL-3412). A module-scoped set is empty\n * where it is read, and every push is silently skipped.\n */\nconst SENDERS_KEY = Symbol.for('@aglyn/tenant-data-admin:mobile-push-senders')\n\nconst globalScope = globalThis as typeof globalThis & {\n [SENDERS_KEY]?: Set<MobilePushSender>\n}\n\nfunction senders(): Set<MobilePushSender> {\n const existing = globalScope[SENDERS_KEY]\n if (existing) return existing\n const created = new Set<MobilePushSender>()\n globalScope[SENDERS_KEY] = created\n return created\n}\n\n/**\n * Adds a push transport to the fan-out. `notifyUsers` hands every registered\n * sender the recipients whose preferences say push, after the feed entries\n * are committed and only while the kill switch is up. With none registered,\n * nothing is pushed. Returns the unregister function.\n */\nexport function registerMobilePushSender(sender: MobilePushSender): () => void {\n senders().add(sender)\n return () => {\n senders().delete(sender)\n }\n}\n\n/** The registered push transports, in registration order. */\nexport function mobilePushSenders(): MobilePushSender[] {\n return [...senders()]\n}\n"],"names":["mobilePushEnabled","env","process","trim","SENDERS_KEY","Symbol","for","globalScope","globalThis","senders","existing","created","Set","registerMobilePushSender","sender","add","delete","mobilePushSenders"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAKD;;;;;;;CAOC,GACD,OAAO,SAASA,kBAAkBC,MAA0CC,QAAQD,GAAG;QAC7EA;IAAR,OAAO,EAACA,2BAAAA,GAAG,CAAC,sBAAsB,YAA1BA,2BAA8B,IAAIE,IAAI,OAAO;AACvD;AAgBA;;;;;CAKC,GACD,MAAMC,cAAcC,OAAOC,GAAG,CAAC;AAE/B,MAAMC,cAAcC;AAIpB,SAASC;IACP,MAAMC,WAAWH,WAAW,CAACH,YAAY;IACzC,IAAIM,UAAU,OAAOA;IACrB,MAAMC,UAAU,IAAIC;IACpBL,WAAW,CAACH,YAAY,GAAGO;IAC3B,OAAOA;AACT;AAEA;;;;;CAKC,GACD,OAAO,SAASE,yBAAyBC,MAAwB;IAC/DL,UAAUM,GAAG,CAACD;IACd,OAAO;QACLL,UAAUO,MAAM,CAACF;IACnB;AACF;AAEA,2DAA2D,GAC3D,OAAO,SAASG;IACd,OAAO;WAAIR;KAAU;AACvB"}
@@ -22,10 +22,13 @@
22
22
  * `push-delivery.ts`, and through it each transport, only when a
23
23
  * notification has push recipients.
24
24
  */ import { registerMobilePushSender } from "./mobile-push-switch.js";
25
- let registered = false;
25
+ // On `globalThis` for the same reason as the registry itself: a second
26
+ // module copy must not register a second sender and push everything twice.
27
+ const REGISTERED_KEY = Symbol.for('@aglyn/tenant-data-admin:native-push-registered');
28
+ const globalScope = globalThis;
26
29
  export function registerNativePushSenders() {
27
- if (registered) return;
28
- registered = true;
30
+ if (globalScope[REGISTERED_KEY]) return;
31
+ globalScope[REGISTERED_KEY] = true;
29
32
  registerMobilePushSender(async (uids, payload, context)=>{
30
33
  const { nativePushSender } = await import("./push-delivery.js");
31
34
  await nativePushSender(uids, payload, context);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/native-push.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 * Registers the native apps' push delivery (APNs and FCM, AGL-3651) with the\n * fan-out, once per server instance. A deployment's server entry calls it at\n * boot (the console's and the tenant's `instrumentation.ts`).\n *\n * This file imports nothing heavy: the sender it registers imports\n * `push-delivery.ts`, and through it each transport, only when a\n * notification has push recipients.\n */\n\nimport { registerMobilePushSender } from './mobile-push-switch'\n\nlet registered = false\n\nexport function registerNativePushSenders(): void {\n if (registered) return\n registered = true\n registerMobilePushSender(async (uids, payload, context) => {\n const { nativePushSender } = await import('./push-delivery')\n await nativePushSender(uids, payload, context)\n })\n}\n"],"names":["registerMobilePushSender","registered","registerNativePushSenders","uids","payload","context","nativePushSender"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;CAQC,GAED,SAASA,wBAAwB,QAAQ,0BAAsB;AAE/D,IAAIC,aAAa;AAEjB,OAAO,SAASC;IACd,IAAID,YAAY;IAChBA,aAAa;IACbD,yBAAyB,OAAOG,MAAMC,SAASC;QAC7C,MAAM,EAAEC,gBAAgB,EAAE,GAAG,MAAM,MAAM,CAAC;QAC1C,MAAMA,iBAAiBH,MAAMC,SAASC;IACxC;AACF"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/native-push.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 * Registers the native apps' push delivery (APNs and FCM, AGL-3651) with the\n * fan-out, once per server instance. A deployment's server entry calls it at\n * boot (the console's and the tenant's `instrumentation.ts`).\n *\n * This file imports nothing heavy: the sender it registers imports\n * `push-delivery.ts`, and through it each transport, only when a\n * notification has push recipients.\n */\n\nimport { registerMobilePushSender } from './mobile-push-switch'\n\n// On `globalThis` for the same reason as the registry itself: a second\n// module copy must not register a second sender and push everything twice.\nconst REGISTERED_KEY = Symbol.for('@aglyn/tenant-data-admin:native-push-registered')\n\nconst globalScope = globalThis as typeof globalThis & { [REGISTERED_KEY]?: true }\n\nexport function registerNativePushSenders(): void {\n if (globalScope[REGISTERED_KEY]) return\n globalScope[REGISTERED_KEY] = true\n registerMobilePushSender(async (uids, payload, context) => {\n const { nativePushSender } = await import('./push-delivery')\n await nativePushSender(uids, payload, context)\n })\n}\n"],"names":["registerMobilePushSender","REGISTERED_KEY","Symbol","for","globalScope","globalThis","registerNativePushSenders","uids","payload","context","nativePushSender"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;CAQC,GAED,SAASA,wBAAwB,QAAQ,0BAAsB;AAE/D,uEAAuE;AACvE,2EAA2E;AAC3E,MAAMC,iBAAiBC,OAAOC,GAAG,CAAC;AAElC,MAAMC,cAAcC;AAEpB,OAAO,SAASC;IACd,IAAIF,WAAW,CAACH,eAAe,EAAE;IACjCG,WAAW,CAACH,eAAe,GAAG;IAC9BD,yBAAyB,OAAOO,MAAMC,SAASC;QAC7C,MAAM,EAAEC,gBAAgB,EAAE,GAAG,MAAM,MAAM,CAAC;QAC1C,MAAMA,iBAAiBH,MAAMC,SAASC;IACxC;AACF"}
@@ -77,7 +77,7 @@ export declare function formatNotificationEmailDetails(details: readonly Notific
77
77
  * (AGL-3224) — sends one email beside it. Never throws: a notification miss,
78
78
  * or a send that failed, must not break the mutation that emitted it.
79
79
  */
80
- export declare function notifyUsers(uids: Iterable<string>, payload: NotificationPayload, options?: NotifyUsersOptions): Promise<void>;
80
+ declare function notifyUsersNow(uids: Iterable<string>, payload: NotificationPayload, options?: NotifyUsersOptions): Promise<void>;
81
81
  /**
82
82
  * Notifies every staff-claim holder (AGL-850) — the support-desk audience.
83
83
  * Staff are not org members, so `notifyOrgAdmins`/`notifyHostManagers` never
@@ -90,13 +90,13 @@ export declare function notifyUsers(uids: Iterable<string>, payload: Notificatio
90
90
  * instead: the same console notification, plus staff's switches, the
91
91
  * operator email or digest, and the webhook. See `raiseOperatorAlert`.
92
92
  */
93
- export declare function notifyStaff(payload: NotificationPayload): Promise<void>;
93
+ declare function notifyStaffNow(payload: NotificationPayload): Promise<void>;
94
94
  /**
95
95
  * The staff console notification alone, with the per-person email channel
96
96
  * it carries: what `notifyStaff` did before the registry, and what
97
97
  * `raiseOperatorAlert` writes as its first step. Never throws.
98
98
  */
99
- export declare function notifyStaffConsole(payload: NotificationPayload, options?: {
99
+ declare function notifyStaffConsoleNow(payload: NotificationPayload, options?: {
100
100
  skipEmail?: boolean;
101
101
  }): Promise<void>;
102
102
  /**
@@ -106,7 +106,7 @@ export declare function notifyStaffConsole(payload: NotificationPayload, options
106
106
  * itself — the usage alerts do (AGL-3431) — so an admin who switched the
107
107
  * email channel on is not sent it twice.
108
108
  */
109
- export declare function notifyOrgAdmins(orgId: string, payload: NotificationPayload, options?: Pick<NotifyUsersOptions, 'skipEmail'>): Promise<void>;
109
+ declare function notifyOrgAdminsNow(orgId: string, payload: NotificationPayload, options?: Pick<NotifyUsersOptions, 'skipEmail'>): Promise<void>;
110
110
  /**
111
111
  * The name a site's managers know it by: the name they gave it, then its
112
112
  * address, then — for a doc with neither — its id.
@@ -141,7 +141,7 @@ export declare function withSiteName(text: string, site: string): string;
141
141
  * read. Spread conditionally: the field is optional on the host model and
142
142
  * Firestore rejects `undefined`.
143
143
  */
144
- export declare function notifyHostManagers(hostId: string, payload: NotificationPayload, options?: {
144
+ declare function notifyHostManagersNow(hostId: string, payload: NotificationPayload, options?: {
145
145
  /**
146
146
  * Mail the workspace owner nothing through the email channel, because
147
147
  * the caller sends them their own email about the same event.
@@ -150,3 +150,14 @@ export declare function notifyHostManagers(hostId: string, payload: Notification
150
150
  /** What the email alone carries; see {@link NotifyUsersOptions.emailDetails}. */
151
151
  emailDetails?: readonly NotificationEmailDetail[];
152
152
  }): Promise<void>;
153
+ /** {@link notifyUsersNow}, kept alive past the response. */
154
+ export declare function notifyUsers(...args: Parameters<typeof notifyUsersNow>): Promise<void>;
155
+ /** {@link notifyStaffNow}, kept alive past the response. */
156
+ export declare function notifyStaff(...args: Parameters<typeof notifyStaffNow>): Promise<void>;
157
+ /** {@link notifyStaffConsoleNow}, kept alive past the response. */
158
+ export declare function notifyStaffConsole(...args: Parameters<typeof notifyStaffConsoleNow>): Promise<void>;
159
+ /** {@link notifyOrgAdminsNow}, kept alive past the response. */
160
+ export declare function notifyOrgAdmins(...args: Parameters<typeof notifyOrgAdminsNow>): Promise<void>;
161
+ /** {@link notifyHostManagersNow}, kept alive past the response. */
162
+ export declare function notifyHostManagers(...args: Parameters<typeof notifyHostManagersNow>): Promise<void>;
163
+ export {};
@@ -25,6 +25,7 @@ import { findUserByUidAcrossPools, listStaffUidsAcrossPools } from "./auth-pools
25
25
  import { filterSuppressedEmails } from "./email-suppression.js";
26
26
  import { meterOrgEmail, meterPlatformEmail } from "./email-metering.js";
27
27
  import firebaseAdmin from "./firebase-admin.js";
28
+ import { keepAlive } from "./keep-alive.js";
28
29
  import { mobilePushEnabled, mobilePushSenders } from "./mobile-push-switch.js";
29
30
  import { listOrgMembers } from "./organizations.js";
30
31
  import { loadSystemEmail, renderLoadedSystemEmail, systemEmailBrand, systemEmailContent } from "./render-system-email.js";
@@ -47,10 +48,10 @@ const firestore = ()=>firebaseAdmin.app().firestore();
47
48
  * At most this many directory lookups per fan-out, and at most this many
48
49
  * notification emails.
49
50
  *
50
- * Both ceilings only ever bite on a fan-out where many recipients have
51
- * OPTED IN — the email channel defaults off outside a site's transactions
52
- * (`NOTIFICATION_TYPE_CHANNEL_DEFAULTS`), so the ordinary notification
53
- * costs exactly what it cost before this existed. They are here because a
51
+ * Both ceilings bite only on a fan-out where many recipients are emailed —
52
+ * the email channel defaults off outside the types in
53
+ * `NOTIFICATION_TYPE_CHANNEL_DEFAULTS`, and every fan-out goes to one
54
+ * workspace's or one site's people, or to staff. They are here because a
54
55
  * fan-out takes up to 400 uids and `notifyUsers` runs inside the mutation
55
56
  * that emitted it: a notification must not be able to turn one write into
56
57
  * four hundred directory reads and four hundred sends.
@@ -199,7 +200,7 @@ const NOTIFY_EMAIL_MAX_SENDS = 50;
199
200
  * `users/{uid}/notifications`, and — for recipients who asked for it
200
201
  * (AGL-3224) — sends one email beside it. Never throws: a notification miss,
201
202
  * or a send that failed, must not break the mutation that emitted it.
202
- */ export async function notifyUsers(uids, payload, options = {}) {
203
+ */ async function notifyUsersNow(uids, payload, options = {}) {
203
204
  try {
204
205
  const db = firestore();
205
206
  const targets = [
@@ -315,7 +316,7 @@ async function listStaffUids() {
315
316
  * alert, a dispute nobody owns, a support ticket — is raised as that alert
316
317
  * instead: the same console notification, plus staff's switches, the
317
318
  * operator email or digest, and the webhook. See `raiseOperatorAlert`.
318
- */ export async function notifyStaff(payload) {
319
+ */ async function notifyStaffNow(payload) {
319
320
  try {
320
321
  const alert = operatorAlertForNotificationType(payload.type);
321
322
  if (alert) {
@@ -344,7 +345,7 @@ async function listStaffUids() {
344
345
  * The staff console notification alone, with the per-person email channel
345
346
  * it carries: what `notifyStaff` did before the registry, and what
346
347
  * `raiseOperatorAlert` writes as its first step. Never throws.
347
- */ export async function notifyStaffConsole(payload, options = {}) {
348
+ */ async function notifyStaffConsoleNow(payload, options = {}) {
348
349
  try {
349
350
  await notifyUsers(await listStaffUids(), payload, _extends({
350
351
  audience: 'staff'
@@ -361,7 +362,7 @@ async function listStaffUids() {
361
362
  * `skipEmail` is for a caller that emails the same notice to the same people
362
363
  * itself — the usage alerts do (AGL-3431) — so an admin who switched the
363
364
  * email channel on is not sent it twice.
364
- */ export async function notifyOrgAdmins(orgId, payload, options = {}) {
365
+ */ async function notifyOrgAdminsNow(orgId, payload, options = {}) {
365
366
  try {
366
367
  const members = await listOrgMembers(orgId);
367
368
  const admins = members.filter((member)=>member.role === 'owner' || member.role === 'admin');
@@ -423,7 +424,7 @@ async function listStaffUids() {
423
424
  * The host doc already carries `orgId` (AGL-233), so this costs no extra
424
425
  * read. Spread conditionally: the field is optional on the host model and
425
426
  * Firestore rejects `undefined`.
426
- */ export async function notifyHostManagers(hostId, payload, options = {}) {
427
+ */ async function notifyHostManagersNow(hostId, payload, options = {}) {
427
428
  try {
428
429
  var _host_get, _payload_orgId;
429
430
  const host = await firestore().collection('hosts').doc(hostId).get();
@@ -451,5 +452,25 @@ async function listStaffUids() {
451
452
  console.error('host manager notification failed', error);
452
453
  }
453
454
  }
455
+ /*
456
+ * The fan-out's public doors. Most callers start a notification with
457
+ * `void notify…()` beside the mutation it reports, so each door hands its
458
+ * work to the platform's `waitUntil` (`keep-alive.ts`): the push and the email
459
+ * after the feed write are no longer cut off when the response goes out.
460
+ */ /** {@link notifyUsersNow}, kept alive past the response. */ export function notifyUsers(...args) {
461
+ return keepAlive(notifyUsersNow(...args));
462
+ }
463
+ /** {@link notifyStaffNow}, kept alive past the response. */ export function notifyStaff(...args) {
464
+ return keepAlive(notifyStaffNow(...args));
465
+ }
466
+ /** {@link notifyStaffConsoleNow}, kept alive past the response. */ export function notifyStaffConsole(...args) {
467
+ return keepAlive(notifyStaffConsoleNow(...args));
468
+ }
469
+ /** {@link notifyOrgAdminsNow}, kept alive past the response. */ export function notifyOrgAdmins(...args) {
470
+ return keepAlive(notifyOrgAdminsNow(...args));
471
+ }
472
+ /** {@link notifyHostManagersNow}, kept alive past the response. */ export function notifyHostManagers(...args) {
473
+ return keepAlive(notifyHostManagersNow(...args));
474
+ }
454
475
 
455
476
  //# sourceMappingURL=notifications.js.map