@aglyn/aglyn 1.0.0-beta.229 → 1.0.0-beta.231
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.
- package/package.json +11 -11
- package/src/lib/app-utils/analytics-events.d.ts +18 -0
- package/src/lib/app-utils/analytics-events.js +2 -0
- package/src/lib/app-utils/analytics-events.js.map +1 -1
- package/src/lib/app-utils/crm.d.ts +14 -1
- package/src/lib/app-utils/crm.js +19 -2
- package/src/lib/app-utils/crm.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +111 -9
- package/src/lib/app-utils/docs-help.generated.js +272 -3
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +810 -61
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/host-status.d.ts +85 -0
- package/src/lib/app-utils/host-status.js +115 -0
- package/src/lib/app-utils/host-status.js.map +1 -0
- package/src/lib/app-utils/lockdown.js +1 -1
- package/src/lib/app-utils/lockdown.js.map +1 -1
- package/src/lib/app-utils/media-filter.d.ts +136 -0
- package/src/lib/app-utils/media-filter.js +400 -0
- package/src/lib/app-utils/media-filter.js.map +1 -0
- package/src/lib/app-utils/mobile-push.d.ts +98 -0
- package/src/lib/app-utils/mobile-push.js +97 -0
- package/src/lib/app-utils/mobile-push.js.map +1 -0
- package/src/lib/app-utils/notification-push.d.ts +38 -0
- package/src/lib/app-utils/notification-push.js +54 -0
- package/src/lib/app-utils/notification-push.js.map +1 -0
- package/src/lib/app-utils/notifications.d.ts +7 -0
- package/src/lib/app-utils/notifications.js.map +1 -1
- package/src/lib/app-utils/organizations.js +5 -2
- package/src/lib/app-utils/organizations.js.map +1 -1
- package/src/lib/app-utils/plan-entitlements.js +20 -0
- package/src/lib/app-utils/plan-entitlements.js.map +1 -1
- package/src/lib/app-utils/plugin-host-events.generated.d.ts +1 -1
- package/src/lib/app-utils/plugin-host-events.generated.js +164 -0
- package/src/lib/app-utils/plugin-host-events.generated.js.map +1 -1
- package/src/lib/app-utils/plugin-release-flags.generated.d.ts +1 -1
- package/src/lib/app-utils/plugin-release-flags.generated.js +7 -0
- package/src/lib/app-utils/plugin-release-flags.generated.js.map +1 -1
- package/src/lib/app-utils/realm-host-surface.generated.js +3 -0
- package/src/lib/app-utils/realm-host-surface.generated.js.map +1 -1
- package/src/lib/app-utils/release-flags.js +6 -2
- package/src/lib/app-utils/release-flags.js.map +1 -1
- package/src/lib/app-utils/scope-tokens.d.ts +16 -1
- package/src/lib/app-utils/scope-tokens.js +15 -1
- package/src/lib/app-utils/scope-tokens.js.map +1 -1
- package/src/lib/app-utils/site-journey.d.ts +143 -0
- package/src/lib/app-utils/site-journey.js +282 -0
- package/src/lib/app-utils/site-journey.js.map +1 -0
- package/src/lib/app-utils/site-list-query.d.ts +47 -0
- package/src/lib/app-utils/site-list-query.js +142 -0
- package/src/lib/app-utils/site-list-query.js.map +1 -0
- package/src/lib/app-utils/site-wide-outbox.d.ts +95 -0
- package/src/lib/app-utils/site-wide-outbox.js +117 -0
- package/src/lib/app-utils/site-wide-outbox.js.map +1 -0
- package/src/lib/app-utils/transfer-launcher-context.d.ts +6 -0
- package/src/lib/app-utils/transfer-launcher-context.js.map +1 -1
- package/src/lib/app-utils/upload-inspection.js +7 -0
- package/src/lib/app-utils/upload-inspection.js.map +1 -1
- package/src/lib/app-utils/webhook-delivery.js +4 -1
- package/src/lib/app-utils/webhook-delivery.js.map +1 -1
- package/src/lib/foundation/definitions/org-billing.types.d.ts +24 -0
- package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
- package/src/lib/foundation/definitions/organization.types.d.ts +18 -7
- package/src/lib/foundation/definitions/organization.types.js.map +1 -1
- package/src/lib/foundation/definitions/write-deny-coverage.util.d.ts +4 -1
- package/src/lib/foundation/definitions/write-deny-coverage.util.js +12 -2
- package/src/lib/foundation/definitions/write-deny-coverage.util.js.map +1 -1
- package/src/lib/plugin-manager/enabled-plugins.js +4 -2
- package/src/lib/plugin-manager/enabled-plugins.js.map +1 -1
- package/src/lib/plugin-manager/feature-plugins.d.ts +173 -0
- package/src/lib/plugin-manager/feature-plugins.js +62 -1
- package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
- package/src/lib/plugin-manager/first-party-plugins.generated.js +251 -2
- package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
- package/src/lib/plugin-manager/plugin-ai-capabilities.d.ts +192 -0
- package/src/lib/plugin-manager/plugin-ai-capabilities.js +157 -0
- package/src/lib/plugin-manager/plugin-ai-capabilities.js.map +1 -0
- package/src/lib/plugin-manager/plugin-checkout-extras.d.ts +168 -0
- package/src/lib/plugin-manager/plugin-checkout-extras.js +172 -0
- package/src/lib/plugin-manager/plugin-checkout-extras.js.map +1 -0
- package/src/lib/plugin-manager/plugin-contributions.d.ts +7 -0
- package/src/lib/plugin-manager/plugin-contributions.js +1 -1
- package/src/lib/plugin-manager/plugin-contributions.js.map +1 -1
- package/src/lib/plugin-manager/plugin-domain-events.d.ts +138 -0
- package/src/lib/plugin-manager/plugin-domain-events.js +148 -0
- package/src/lib/plugin-manager/plugin-domain-events.js.map +1 -0
- package/src/lib/plugin-manager/plugin-events.d.ts +51 -0
- package/src/lib/plugin-manager/plugin-events.js +4 -0
- package/src/lib/plugin-manager/plugin-events.js.map +1 -1
- package/src/lib/plugin-manager/plugin-fulfillment-providers.d.ts +101 -0
- package/src/lib/plugin-manager/plugin-fulfillment-providers.js +83 -0
- package/src/lib/plugin-manager/plugin-fulfillment-providers.js.map +1 -0
- package/src/lib/plugin-manager/plugin-permissions.js +21 -5
- package/src/lib/plugin-manager/plugin-permissions.js.map +1 -1
- package/src/lib/plugin-manager/plugin-person-records.d.ts +90 -0
- package/src/lib/plugin-manager/plugin-person-records.js +26 -0
- package/src/lib/plugin-manager/plugin-person-records.js.map +1 -1
- package/src/lib/plugin-manager/plugin-product-catalog.d.ts +204 -0
- package/src/lib/plugin-manager/plugin-product-catalog.js +43 -0
- package/src/lib/plugin-manager/plugin-product-catalog.js.map +1 -0
- package/src/lib/plugin-manager/plugin-shipment-records.d.ts +210 -0
- package/src/lib/plugin-manager/plugin-shipment-records.js +63 -0
- package/src/lib/plugin-manager/plugin-shipment-records.js.map +1 -0
- package/src/lib/plugin-manager/plugin-shipping-rates.d.ts +151 -0
- package/src/lib/plugin-manager/plugin-shipping-rates.js +62 -0
- package/src/lib/plugin-manager/plugin-shipping-rates.js.map +1 -0
- package/src/lib/plugin-manager/plugin-sms-messaging.d.ts +103 -0
- package/src/lib/plugin-manager/plugin-sms-messaging.js +39 -0
- package/src/lib/plugin-manager/plugin-sms-messaging.js.map +1 -0
- package/src/lib/plugin-manager/plugin-stock-levels.d.ts +81 -0
- package/src/lib/plugin-manager/plugin-stock-levels.js +32 -0
- package/src/lib/plugin-manager/plugin-stock-levels.js.map +1 -0
- package/src/lib/plugin-manager/plugin-tax-profile.d.ts +154 -0
- package/src/lib/plugin-manager/plugin-tax-profile.js +56 -0
- package/src/lib/plugin-manager/plugin-tax-profile.js.map +1 -1
- package/src/lib/plugin-manager/plugin-theme-font-catalog.d.ts +59 -0
- package/src/lib/plugin-manager/plugin-theme-font-catalog.js +40 -0
- package/src/lib/plugin-manager/plugin-theme-font-catalog.js.map +1 -0
- package/src/lib/plugin-manager/plugin-tracking-pages.d.ts +55 -0
- package/src/lib/plugin-manager/plugin-tracking-pages.js +76 -0
- package/src/lib/plugin-manager/plugin-tracking-pages.js.map +1 -0
- package/src/lib/plugin-manager/realm-host-aglyn.generated.js +3 -0
- package/src/lib/plugin-manager/realm-host-aglyn.generated.js.map +1 -1
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
* WHAT THE SITES CARDS ASK, AND OF WHICH COLLECTION (AGL-3321).
|
|
18
|
+
*
|
|
19
|
+
* The Sites page is a grid of site CARDS, and stays one: the filters and the
|
|
20
|
+
* search sit over the cards, and the cards are drawn exactly as before. What
|
|
21
|
+
* changed is which sites the cards are drawn for. They are a paged query over
|
|
22
|
+
* the reader's own site memberships, `users/{uid}/hostMemberships` —
|
|
23
|
+
* `where(orgId ==)` for the workspace, the filters' clauses and the search
|
|
24
|
+
* word, in ONE order — and each page's rows are joined by id to the host
|
|
25
|
+
* documents `useOrgHosts` already holds, for display only. Nothing is matched
|
|
26
|
+
* over the sites already loaded.
|
|
27
|
+
*
|
|
28
|
+
* Not `hosts`: the rules serve a non-staff LIST of `hosts` only under a
|
|
29
|
+
* `memberRoles.{uid}` clause, a composite index on that per-user map path can
|
|
30
|
+
* exist for no order but the document id, and a predicated listen over the
|
|
31
|
+
* mutable `memberRoles`/`orgId` tombstoned host documents for every other
|
|
32
|
+
* reader (AGL-1190). The membership rows are owner-readable with no
|
|
33
|
+
* `resource.data` term, so any `where` is allowed on them.
|
|
34
|
+
*
|
|
35
|
+
* So the cards offer exactly what a row carries (`membershipRow` in
|
|
36
|
+
* `@aglyn/tenant-data-admin`), each re-derived from the host on every
|
|
37
|
+
* re-sync and stamped on older rows by
|
|
38
|
+
* `tools/scripts/backfill-host-memberships-list-fields.mjs`:
|
|
39
|
+
*
|
|
40
|
+
* - search: `searchTokens`, the start of a word of the name, the subdomain
|
|
41
|
+
* (the site's slug) or the custom domain;
|
|
42
|
+
* - Custom domain: `hasCustomDomain`, connected or none — an equality;
|
|
43
|
+
* - Created: the day ranges, on the host's own `createdAt`. A range orders
|
|
44
|
+
* the query by the field it ranges over, so under it the cards run
|
|
45
|
+
* newest first; otherwise they run by name, A to Z.
|
|
46
|
+
*
|
|
47
|
+
* NOT offered, each because a row cannot answer it honestly:
|
|
48
|
+
*
|
|
49
|
+
* - Status (Live, Draft, Maintenance, Suspended) is derived from
|
|
50
|
+
* `suspendedAt`, `suspendedUntilMs`, `maintenance` and the `screens`
|
|
51
|
+
* publish map. A timed suspension ENDS with no write at all, so a stored
|
|
52
|
+
* status is wrong the moment it lapses; and those fields are written by
|
|
53
|
+
* the console's publish batches in the browser, the tenant's scheduled
|
|
54
|
+
* publish, the error-screen save and the staff lockdown routes, none of
|
|
55
|
+
* which re-syncs a mirror.
|
|
56
|
+
* - Updated is the host's `updatedAt`, which nearly every write to a site
|
|
57
|
+
* moves without a re-sync; and the list takes ONE range, Created.
|
|
58
|
+
* - Plan, owner (created by) and template: a site document stores none of
|
|
59
|
+
* them. The plan is the organization's, the same for every card here.
|
|
60
|
+
*/ /** The collection group the list queries, and that its indexes are declared on. */ export const SITE_LIST_COLLECTION_GROUP = 'hostMemberships';
|
|
61
|
+
export const SITE_FILTER_FIELDS = [
|
|
62
|
+
{
|
|
63
|
+
column: 'hasCustomDomain',
|
|
64
|
+
kind: 'boolean',
|
|
65
|
+
path: 'hasCustomDomain',
|
|
66
|
+
operators: [
|
|
67
|
+
'is'
|
|
68
|
+
]
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
column: 'createdAt',
|
|
72
|
+
kind: 'date',
|
|
73
|
+
path: 'createdAt',
|
|
74
|
+
// A site with no recorded date carries `null`, which no range matches;
|
|
75
|
+
// `is empty` and `is not empty` are not offered, since `!= null` would be
|
|
76
|
+
// a second range beside the day ones.
|
|
77
|
+
operators: [
|
|
78
|
+
'is',
|
|
79
|
+
'after',
|
|
80
|
+
'onOrAfter',
|
|
81
|
+
'before',
|
|
82
|
+
'onOrBefore'
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
];
|
|
86
|
+
/** How each filter reads on a chip, a notice and the Filters panel. */ export const SITE_FILTER_HEADERS = {
|
|
87
|
+
hasCustomDomain: 'Custom domain',
|
|
88
|
+
createdAt: 'Created'
|
|
89
|
+
};
|
|
90
|
+
/** The Custom domain filter's two answers, by the words the card uses. */ export const SITE_FILTER_OPTIONS = {
|
|
91
|
+
hasCustomDomain: [
|
|
92
|
+
{
|
|
93
|
+
value: 'true',
|
|
94
|
+
label: 'Connected'
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
value: 'false',
|
|
98
|
+
label: 'None'
|
|
99
|
+
}
|
|
100
|
+
]
|
|
101
|
+
};
|
|
102
|
+
/** The cards' own order, and the default: by name, A to Z. */ export const SITE_LIST_NAME_SORT = {
|
|
103
|
+
path: 'nameLower',
|
|
104
|
+
direction: 'asc'
|
|
105
|
+
};
|
|
106
|
+
export const SITE_LIST_DECLARATION = {
|
|
107
|
+
fields: SITE_FILTER_FIELDS,
|
|
108
|
+
sorts: [
|
|
109
|
+
SITE_LIST_NAME_SORT,
|
|
110
|
+
// The order a Created range imposes: newest first.
|
|
111
|
+
{
|
|
112
|
+
path: 'createdAt',
|
|
113
|
+
direction: 'desc'
|
|
114
|
+
}
|
|
115
|
+
],
|
|
116
|
+
search: {
|
|
117
|
+
tokensPath: 'searchTokens'
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* The list's scope: the workspace's sites, or — for an account with no
|
|
122
|
+
* workspace yet, which `useOrgHosts` lists unscoped too — every site.
|
|
123
|
+
*/ export function siteListBase(orgId) {
|
|
124
|
+
return orgId ? [
|
|
125
|
+
{
|
|
126
|
+
path: 'orgId',
|
|
127
|
+
op: '==',
|
|
128
|
+
value: orgId
|
|
129
|
+
}
|
|
130
|
+
] : [];
|
|
131
|
+
}
|
|
132
|
+
/** The scope's shape, for the index enumeration. */ export const SITE_LIST_INDEX_BASE = [
|
|
133
|
+
{
|
|
134
|
+
path: 'orgId'
|
|
135
|
+
}
|
|
136
|
+
];
|
|
137
|
+
/**
|
|
138
|
+
* How many cards a "Load more" adds: four rows of the three-across grid, so
|
|
139
|
+
* a page never ends on a half-empty row.
|
|
140
|
+
*/ export const SITE_CARDS_PAGE_SIZE = 12;
|
|
141
|
+
|
|
142
|
+
//# sourceMappingURL=site-list-query.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/site-list-query.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 { ListFilterField } from '@aglyn/shared-util-tools/list-query/list-filter'\nimport type {\n ListQueryDeclaration,\n ListQueryFilter,\n ListQuerySort,\n} from '@aglyn/shared-util-tools/list-query/list-query-plan'\n\n/*\n * WHAT THE SITES CARDS ASK, AND OF WHICH COLLECTION (AGL-3321).\n *\n * The Sites page is a grid of site CARDS, and stays one: the filters and the\n * search sit over the cards, and the cards are drawn exactly as before. What\n * changed is which sites the cards are drawn for. They are a paged query over\n * the reader's own site memberships, `users/{uid}/hostMemberships` —\n * `where(orgId ==)` for the workspace, the filters' clauses and the search\n * word, in ONE order — and each page's rows are joined by id to the host\n * documents `useOrgHosts` already holds, for display only. Nothing is matched\n * over the sites already loaded.\n *\n * Not `hosts`: the rules serve a non-staff LIST of `hosts` only under a\n * `memberRoles.{uid}` clause, a composite index on that per-user map path can\n * exist for no order but the document id, and a predicated listen over the\n * mutable `memberRoles`/`orgId` tombstoned host documents for every other\n * reader (AGL-1190). The membership rows are owner-readable with no\n * `resource.data` term, so any `where` is allowed on them.\n *\n * So the cards offer exactly what a row carries (`membershipRow` in\n * `@aglyn/tenant-data-admin`), each re-derived from the host on every\n * re-sync and stamped on older rows by\n * `tools/scripts/backfill-host-memberships-list-fields.mjs`:\n *\n * - search: `searchTokens`, the start of a word of the name, the subdomain\n * (the site's slug) or the custom domain;\n * - Custom domain: `hasCustomDomain`, connected or none — an equality;\n * - Created: the day ranges, on the host's own `createdAt`. A range orders\n * the query by the field it ranges over, so under it the cards run\n * newest first; otherwise they run by name, A to Z.\n *\n * NOT offered, each because a row cannot answer it honestly:\n *\n * - Status (Live, Draft, Maintenance, Suspended) is derived from\n * `suspendedAt`, `suspendedUntilMs`, `maintenance` and the `screens`\n * publish map. A timed suspension ENDS with no write at all, so a stored\n * status is wrong the moment it lapses; and those fields are written by\n * the console's publish batches in the browser, the tenant's scheduled\n * publish, the error-screen save and the staff lockdown routes, none of\n * which re-syncs a mirror.\n * - Updated is the host's `updatedAt`, which nearly every write to a site\n * moves without a re-sync; and the list takes ONE range, Created.\n * - Plan, owner (created by) and template: a site document stores none of\n * them. The plan is the organization's, the same for every card here.\n */\n\n/** The collection group the list queries, and that its indexes are declared on. */\nexport const SITE_LIST_COLLECTION_GROUP = 'hostMemberships'\n\nexport const SITE_FILTER_FIELDS: readonly ListFilterField[] = [\n {\n column: 'hasCustomDomain',\n kind: 'boolean',\n path: 'hasCustomDomain',\n operators: ['is'],\n },\n {\n column: 'createdAt',\n kind: 'date',\n path: 'createdAt',\n // A site with no recorded date carries `null`, which no range matches;\n // `is empty` and `is not empty` are not offered, since `!= null` would be\n // a second range beside the day ones.\n operators: ['is', 'after', 'onOrAfter', 'before', 'onOrBefore'],\n },\n]\n\n/** How each filter reads on a chip, a notice and the Filters panel. */\nexport const SITE_FILTER_HEADERS: Readonly<Record<string, string>> = {\n hasCustomDomain: 'Custom domain',\n createdAt: 'Created',\n}\n\n/** The Custom domain filter's two answers, by the words the card uses. */\nexport const SITE_FILTER_OPTIONS = {\n hasCustomDomain: [\n { value: 'true', label: 'Connected' },\n { value: 'false', label: 'None' },\n ],\n}\n\n/** The cards' own order, and the default: by name, A to Z. */\nexport const SITE_LIST_NAME_SORT: ListQuerySort = {\n path: 'nameLower',\n direction: 'asc',\n}\n\nexport const SITE_LIST_DECLARATION: ListQueryDeclaration = {\n fields: SITE_FILTER_FIELDS,\n sorts: [\n SITE_LIST_NAME_SORT,\n // The order a Created range imposes: newest first.\n { path: 'createdAt', direction: 'desc' },\n ],\n search: { tokensPath: 'searchTokens' },\n}\n\n/**\n * The list's scope: the workspace's sites, or — for an account with no\n * workspace yet, which `useOrgHosts` lists unscoped too — every site.\n */\nexport function siteListBase(orgId: string | null): ListQueryFilter[] {\n return orgId ? [{ path: 'orgId', op: '==', value: orgId }] : []\n}\n\n/** The scope's shape, for the index enumeration. */\nexport const SITE_LIST_INDEX_BASE: ReadonlyArray<{ path: string }> = [{ path: 'orgId' }]\n\n/**\n * How many cards a \"Load more\" adds: four rows of the three-across grid, so\n * a page never ends on a half-empty row.\n */\nexport const SITE_CARDS_PAGE_SIZE = 12\n"],"names":["SITE_LIST_COLLECTION_GROUP","SITE_FILTER_FIELDS","column","kind","path","operators","SITE_FILTER_HEADERS","hasCustomDomain","createdAt","SITE_FILTER_OPTIONS","value","label","SITE_LIST_NAME_SORT","direction","SITE_LIST_DECLARATION","fields","sorts","search","tokensPath","siteListBase","orgId","op","SITE_LIST_INDEX_BASE","SITE_CARDS_PAGE_SIZE"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GASD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4CC,GAED,iFAAiF,GACjF,OAAO,MAAMA,6BAA6B,kBAAiB;AAE3D,OAAO,MAAMC,qBAAiD;IAC5D;QACEC,QAAQ;QACRC,MAAM;QACNC,MAAM;QACNC,WAAW;YAAC;SAAK;IACnB;IACA;QACEH,QAAQ;QACRC,MAAM;QACNC,MAAM;QACN,uEAAuE;QACvE,0EAA0E;QAC1E,sCAAsC;QACtCC,WAAW;YAAC;YAAM;YAAS;YAAa;YAAU;SAAa;IACjE;CACD,CAAA;AAED,qEAAqE,GACrE,OAAO,MAAMC,sBAAwD;IACnEC,iBAAiB;IACjBC,WAAW;AACb,EAAC;AAED,wEAAwE,GACxE,OAAO,MAAMC,sBAAsB;IACjCF,iBAAiB;QACf;YAAEG,OAAO;YAAQC,OAAO;QAAY;QACpC;YAAED,OAAO;YAASC,OAAO;QAAO;KACjC;AACH,EAAC;AAED,4DAA4D,GAC5D,OAAO,MAAMC,sBAAqC;IAChDR,MAAM;IACNS,WAAW;AACb,EAAC;AAED,OAAO,MAAMC,wBAA8C;IACzDC,QAAQd;IACRe,OAAO;QACLJ;QACA,mDAAmD;QACnD;YAAER,MAAM;YAAaS,WAAW;QAAO;KACxC;IACDI,QAAQ;QAAEC,YAAY;IAAe;AACvC,EAAC;AAED;;;CAGC,GACD,OAAO,SAASC,aAAaC,KAAoB;IAC/C,OAAOA,QAAQ;QAAC;YAAEhB,MAAM;YAASiB,IAAI;YAAMX,OAAOU;QAAM;KAAE,GAAG,EAAE;AACjE;AAEA,kDAAkD,GAClD,OAAO,MAAME,uBAAwD;IAAC;QAAElB,MAAM;IAAQ;CAAE,CAAA;AAExF;;;CAGC,GACD,OAAO,MAAMmB,uBAAuB,GAAE"}
|
|
@@ -0,0 +1,95 @@
|
|
|
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 DocumentReference, type Firestore } from 'firebase/firestore';
|
|
18
|
+
/**
|
|
19
|
+
* A SITE-WIDE CHANGE, SAVED FROM THE BROWSER, AND THE CACHE DROP IT OWES
|
|
20
|
+
* (AGL-3386).
|
|
21
|
+
*
|
|
22
|
+
* A site's settings, and the plugin data its pages render — the theme, the
|
|
23
|
+
* announcement bar, a product, a site variable — are client Firestore writes
|
|
24
|
+
* with no publish step. The live site is ISR-cached for an hour, so each of
|
|
25
|
+
* those writes has to tell the tenant to drop the site's cached pages, or the
|
|
26
|
+
* change waits out the hour while the console says it is saved.
|
|
27
|
+
*
|
|
28
|
+
* Telling it from the tab is the fast path, and a tab is not durable: close
|
|
29
|
+
* it, sleep the laptop or lose the network between the write and the fetch,
|
|
30
|
+
* and the write has landed while the drop has landed nowhere. So the drop is
|
|
31
|
+
* also WRITTEN DOWN, as a publish outbox entry in the same batch as the
|
|
32
|
+
* write — either both land or neither does — and the console's scheduled
|
|
33
|
+
* drain fires any entry the tab did not get to release. This is the same
|
|
34
|
+
* mechanism a screen publish uses (AGL-2575); the entry carries
|
|
35
|
+
* `entireHost: true`, because a site-wide setting has no address of its own.
|
|
36
|
+
*
|
|
37
|
+
* This is the outbox half for the native app (AGL-3621), which saves the
|
|
38
|
+
* same documents and owes the same drop. The browser's copy is
|
|
39
|
+
* `@aglyn/tenant-feature-instance/hooks/helpers/site-wide-change`, left as it
|
|
40
|
+
* was because a web bundle may not grow by a byte for the app; this one takes
|
|
41
|
+
* the entry id from its caller, since the id helper's barrel is not free of
|
|
42
|
+
* React. `site-wide-outbox.spec.ts` beside that file holds the two to the
|
|
43
|
+
* same collection, the same entry and the same retry.
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* The publish outbox. The console's `PUBLISH_OUTBOX_COLLECTION` is the same
|
|
47
|
+
* collection, and `host-document-writes-sweep.spec.ts` holds the two names
|
|
48
|
+
* and the entry's keys to the console's constants and to the rule.
|
|
49
|
+
*/
|
|
50
|
+
export declare const SITE_WIDE_OUTBOX_COLLECTION = "publishOutbox";
|
|
51
|
+
/**
|
|
52
|
+
* What a site-wide entry names. The rule insists every entry names at least
|
|
53
|
+
* one address; the drain reads `entireHost` and drops every routed page of
|
|
54
|
+
* the site instead, which is what a site-wide change needs.
|
|
55
|
+
*/
|
|
56
|
+
export declare const SITE_WIDE_OUTBOX_PATHS: readonly string[];
|
|
57
|
+
/**
|
|
58
|
+
* What an entry can be staged into: a batch, or a transaction — anything
|
|
59
|
+
* whose `set` lands in the same commit as the write beside it.
|
|
60
|
+
*/
|
|
61
|
+
export interface SiteWideEntryWriter {
|
|
62
|
+
set(ref: DocumentReference, data: Record<string, unknown>): unknown;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Stages one site-wide outbox entry into the caller's batch, under `entryId`:
|
|
66
|
+
* the id every console resource carries, never an SDK auto-id.
|
|
67
|
+
*/
|
|
68
|
+
export declare function stageSiteWideOutboxEntryAs(batch: SiteWideEntryWriter, firestore: Firestore, hostId: string, entryId: string): DocumentReference;
|
|
69
|
+
/** Adds a site-wide outbox entry to a batch that is about to commit. */
|
|
70
|
+
export type StageSiteWideEntry = (batch: SiteWideEntryWriter, firestore: Firestore) => void;
|
|
71
|
+
/**
|
|
72
|
+
* Commits a write with its site-wide outbox entry beside it, and answers the
|
|
73
|
+
* entry — or `null` when the write had to land without one.
|
|
74
|
+
*
|
|
75
|
+
* `commit` performs the write. It is handed the stager on the first attempt
|
|
76
|
+
* and must put the entry in the SAME batch as the write; it is called again
|
|
77
|
+
* without one if that batch is refused.
|
|
78
|
+
*
|
|
79
|
+
* The retry is what keeps the entry from ever costing a save. The entry is
|
|
80
|
+
* the durable copy of a cache drop, and the write is the user's work: a rule
|
|
81
|
+
* that refuses the entry — the window between a code release and the rules
|
|
82
|
+
* deploy that admits it, or a role the outbox rule does not cover — must
|
|
83
|
+
* leave the save exactly as it was without it, with the tab's own drop still
|
|
84
|
+
* behind it. A write the rules refuse on its own merits is refused again,
|
|
85
|
+
* and that second refusal is the one the caller sees.
|
|
86
|
+
*/
|
|
87
|
+
export declare function commitWithSiteWideEntryAs(hostId: string, commit: (stage: StageSiteWideEntry | undefined) => Promise<void>, newEntryId: () => string): Promise<DocumentReference | null>;
|
|
88
|
+
/**
|
|
89
|
+
* Releases an entry once its drop is KNOWN to have landed — a plain `ok` from
|
|
90
|
+
* the console route and nothing else. Any other answer leaves it for the
|
|
91
|
+
* drain, because the entry is the only record that the site may still be
|
|
92
|
+
* stale; the cost of keeping one needlessly is a duplicate drop, which is a
|
|
93
|
+
* no-op.
|
|
94
|
+
*/
|
|
95
|
+
export declare function releaseSiteWideOutboxEntry(entry: DocumentReference | null, reason: string | null | undefined): Promise<void>;
|
|
@@ -0,0 +1,117 @@
|
|
|
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 { deleteDoc, doc, serverTimestamp } from "firebase/firestore";
|
|
17
|
+
/**
|
|
18
|
+
* A SITE-WIDE CHANGE, SAVED FROM THE BROWSER, AND THE CACHE DROP IT OWES
|
|
19
|
+
* (AGL-3386).
|
|
20
|
+
*
|
|
21
|
+
* A site's settings, and the plugin data its pages render — the theme, the
|
|
22
|
+
* announcement bar, a product, a site variable — are client Firestore writes
|
|
23
|
+
* with no publish step. The live site is ISR-cached for an hour, so each of
|
|
24
|
+
* those writes has to tell the tenant to drop the site's cached pages, or the
|
|
25
|
+
* change waits out the hour while the console says it is saved.
|
|
26
|
+
*
|
|
27
|
+
* Telling it from the tab is the fast path, and a tab is not durable: close
|
|
28
|
+
* it, sleep the laptop or lose the network between the write and the fetch,
|
|
29
|
+
* and the write has landed while the drop has landed nowhere. So the drop is
|
|
30
|
+
* also WRITTEN DOWN, as a publish outbox entry in the same batch as the
|
|
31
|
+
* write — either both land or neither does — and the console's scheduled
|
|
32
|
+
* drain fires any entry the tab did not get to release. This is the same
|
|
33
|
+
* mechanism a screen publish uses (AGL-2575); the entry carries
|
|
34
|
+
* `entireHost: true`, because a site-wide setting has no address of its own.
|
|
35
|
+
*
|
|
36
|
+
* This is the outbox half for the native app (AGL-3621), which saves the
|
|
37
|
+
* same documents and owes the same drop. The browser's copy is
|
|
38
|
+
* `@aglyn/tenant-feature-instance/hooks/helpers/site-wide-change`, left as it
|
|
39
|
+
* was because a web bundle may not grow by a byte for the app; this one takes
|
|
40
|
+
* the entry id from its caller, since the id helper's barrel is not free of
|
|
41
|
+
* React. `site-wide-outbox.spec.ts` beside that file holds the two to the
|
|
42
|
+
* same collection, the same entry and the same retry.
|
|
43
|
+
*/ /**
|
|
44
|
+
* The publish outbox. The console's `PUBLISH_OUTBOX_COLLECTION` is the same
|
|
45
|
+
* collection, and `host-document-writes-sweep.spec.ts` holds the two names
|
|
46
|
+
* and the entry's keys to the console's constants and to the rule.
|
|
47
|
+
*/ export const SITE_WIDE_OUTBOX_COLLECTION = 'publishOutbox';
|
|
48
|
+
/**
|
|
49
|
+
* What a site-wide entry names. The rule insists every entry names at least
|
|
50
|
+
* one address; the drain reads `entireHost` and drops every routed page of
|
|
51
|
+
* the site instead, which is what a site-wide change needs.
|
|
52
|
+
*/ export const SITE_WIDE_OUTBOX_PATHS = [
|
|
53
|
+
'/'
|
|
54
|
+
];
|
|
55
|
+
/**
|
|
56
|
+
* Stages one site-wide outbox entry into the caller's batch, under `entryId`:
|
|
57
|
+
* the id every console resource carries, never an SDK auto-id.
|
|
58
|
+
*/ export function stageSiteWideOutboxEntryAs(batch, firestore, hostId, entryId) {
|
|
59
|
+
const ref = doc(firestore, SITE_WIDE_OUTBOX_COLLECTION, entryId);
|
|
60
|
+
batch.set(ref, {
|
|
61
|
+
hostId,
|
|
62
|
+
paths: [
|
|
63
|
+
...SITE_WIDE_OUTBOX_PATHS
|
|
64
|
+
],
|
|
65
|
+
// A server timestamp: the rules pin it to `request.time`, and the drain
|
|
66
|
+
// ages entries by it.
|
|
67
|
+
createdAt: serverTimestamp(),
|
|
68
|
+
attempts: 0,
|
|
69
|
+
entireHost: true
|
|
70
|
+
});
|
|
71
|
+
return ref;
|
|
72
|
+
}
|
|
73
|
+
const isPermissionDenied = (error)=>(error == null ? void 0 : error.code) === 'permission-denied';
|
|
74
|
+
/**
|
|
75
|
+
* Commits a write with its site-wide outbox entry beside it, and answers the
|
|
76
|
+
* entry — or `null` when the write had to land without one.
|
|
77
|
+
*
|
|
78
|
+
* `commit` performs the write. It is handed the stager on the first attempt
|
|
79
|
+
* and must put the entry in the SAME batch as the write; it is called again
|
|
80
|
+
* without one if that batch is refused.
|
|
81
|
+
*
|
|
82
|
+
* The retry is what keeps the entry from ever costing a save. The entry is
|
|
83
|
+
* the durable copy of a cache drop, and the write is the user's work: a rule
|
|
84
|
+
* that refuses the entry — the window between a code release and the rules
|
|
85
|
+
* deploy that admits it, or a role the outbox rule does not cover — must
|
|
86
|
+
* leave the save exactly as it was without it, with the tab's own drop still
|
|
87
|
+
* behind it. A write the rules refuse on its own merits is refused again,
|
|
88
|
+
* and that second refusal is the one the caller sees.
|
|
89
|
+
*/ export async function commitWithSiteWideEntryAs(hostId, commit, newEntryId) {
|
|
90
|
+
// A holder rather than a `let`: the stager assigns it inside a callback,
|
|
91
|
+
// which control-flow narrowing cannot see.
|
|
92
|
+
const staged = {
|
|
93
|
+
entry: null
|
|
94
|
+
};
|
|
95
|
+
try {
|
|
96
|
+
await commit((batch, firestore)=>{
|
|
97
|
+
staged.entry = stageSiteWideOutboxEntryAs(batch, firestore, hostId, newEntryId());
|
|
98
|
+
});
|
|
99
|
+
return staged.entry;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (!staged.entry || !isPermissionDenied(error)) throw error;
|
|
102
|
+
await commit(undefined);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Releases an entry once its drop is KNOWN to have landed — a plain `ok` from
|
|
108
|
+
* the console route and nothing else. Any other answer leaves it for the
|
|
109
|
+
* drain, because the entry is the only record that the site may still be
|
|
110
|
+
* stale; the cost of keeping one needlessly is a duplicate drop, which is a
|
|
111
|
+
* no-op.
|
|
112
|
+
*/ export async function releaseSiteWideOutboxEntry(entry, reason) {
|
|
113
|
+
if (!entry || reason !== 'ok') return;
|
|
114
|
+
await deleteDoc(entry).catch(()=>undefined);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
//# sourceMappingURL=site-wide-outbox.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/site-wide-outbox.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 {\n deleteDoc,\n doc,\n serverTimestamp,\n type DocumentReference,\n type Firestore,\n} from 'firebase/firestore'\n\n/**\n * A SITE-WIDE CHANGE, SAVED FROM THE BROWSER, AND THE CACHE DROP IT OWES\n * (AGL-3386).\n *\n * A site's settings, and the plugin data its pages render — the theme, the\n * announcement bar, a product, a site variable — are client Firestore writes\n * with no publish step. The live site is ISR-cached for an hour, so each of\n * those writes has to tell the tenant to drop the site's cached pages, or the\n * change waits out the hour while the console says it is saved.\n *\n * Telling it from the tab is the fast path, and a tab is not durable: close\n * it, sleep the laptop or lose the network between the write and the fetch,\n * and the write has landed while the drop has landed nowhere. So the drop is\n * also WRITTEN DOWN, as a publish outbox entry in the same batch as the\n * write — either both land or neither does — and the console's scheduled\n * drain fires any entry the tab did not get to release. This is the same\n * mechanism a screen publish uses (AGL-2575); the entry carries\n * `entireHost: true`, because a site-wide setting has no address of its own.\n *\n * This is the outbox half for the native app (AGL-3621), which saves the\n * same documents and owes the same drop. The browser's copy is\n * `@aglyn/tenant-feature-instance/hooks/helpers/site-wide-change`, left as it\n * was because a web bundle may not grow by a byte for the app; this one takes\n * the entry id from its caller, since the id helper's barrel is not free of\n * React. `site-wide-outbox.spec.ts` beside that file holds the two to the\n * same collection, the same entry and the same retry.\n */\n\n/**\n * The publish outbox. The console's `PUBLISH_OUTBOX_COLLECTION` is the same\n * collection, and `host-document-writes-sweep.spec.ts` holds the two names\n * and the entry's keys to the console's constants and to the rule.\n */\nexport const SITE_WIDE_OUTBOX_COLLECTION = 'publishOutbox'\n\n/**\n * What a site-wide entry names. The rule insists every entry names at least\n * one address; the drain reads `entireHost` and drops every routed page of\n * the site instead, which is what a site-wide change needs.\n */\nexport const SITE_WIDE_OUTBOX_PATHS: readonly string[] = ['/']\n\n/**\n * What an entry can be staged into: a batch, or a transaction — anything\n * whose `set` lands in the same commit as the write beside it.\n */\nexport interface SiteWideEntryWriter {\n set(ref: DocumentReference, data: Record<string, unknown>): unknown\n}\n\n/**\n * Stages one site-wide outbox entry into the caller's batch, under `entryId`:\n * the id every console resource carries, never an SDK auto-id.\n */\nexport function stageSiteWideOutboxEntryAs(\n batch: SiteWideEntryWriter,\n firestore: Firestore,\n hostId: string,\n entryId: string,\n): DocumentReference {\n const ref = doc(firestore, SITE_WIDE_OUTBOX_COLLECTION, entryId)\n batch.set(ref, {\n hostId,\n paths: [...SITE_WIDE_OUTBOX_PATHS],\n // A server timestamp: the rules pin it to `request.time`, and the drain\n // ages entries by it.\n createdAt: serverTimestamp(),\n attempts: 0,\n entireHost: true,\n })\n return ref\n}\n\n/** Adds a site-wide outbox entry to a batch that is about to commit. */\nexport type StageSiteWideEntry = (\n batch: SiteWideEntryWriter,\n firestore: Firestore,\n) => void\n\nconst isPermissionDenied = (error: unknown): boolean =>\n (error as { code?: unknown } | null)?.code === 'permission-denied'\n\n/**\n * Commits a write with its site-wide outbox entry beside it, and answers the\n * entry — or `null` when the write had to land without one.\n *\n * `commit` performs the write. It is handed the stager on the first attempt\n * and must put the entry in the SAME batch as the write; it is called again\n * without one if that batch is refused.\n *\n * The retry is what keeps the entry from ever costing a save. The entry is\n * the durable copy of a cache drop, and the write is the user's work: a rule\n * that refuses the entry — the window between a code release and the rules\n * deploy that admits it, or a role the outbox rule does not cover — must\n * leave the save exactly as it was without it, with the tab's own drop still\n * behind it. A write the rules refuse on its own merits is refused again,\n * and that second refusal is the one the caller sees.\n */\nexport async function commitWithSiteWideEntryAs(\n hostId: string,\n commit: (stage: StageSiteWideEntry | undefined) => Promise<void>,\n newEntryId: () => string,\n): Promise<DocumentReference | null> {\n // A holder rather than a `let`: the stager assigns it inside a callback,\n // which control-flow narrowing cannot see.\n const staged: { entry: DocumentReference | null } = { entry: null }\n try {\n await commit((batch, firestore) => {\n staged.entry = stageSiteWideOutboxEntryAs(batch, firestore, hostId, newEntryId())\n })\n return staged.entry\n } catch (error) {\n if (!staged.entry || !isPermissionDenied(error)) throw error\n await commit(undefined)\n return null\n }\n}\n\n/**\n * Releases an entry once its drop is KNOWN to have landed — a plain `ok` from\n * the console route and nothing else. Any other answer leaves it for the\n * drain, because the entry is the only record that the site may still be\n * stale; the cost of keeping one needlessly is a duplicate drop, which is a\n * no-op.\n */\nexport async function releaseSiteWideOutboxEntry(\n entry: DocumentReference | null,\n reason: string | null | undefined,\n): Promise<void> {\n if (!entry || reason !== 'ok') return\n await deleteDoc(entry).catch((): void => undefined)\n}\n"],"names":["deleteDoc","doc","serverTimestamp","SITE_WIDE_OUTBOX_COLLECTION","SITE_WIDE_OUTBOX_PATHS","stageSiteWideOutboxEntryAs","batch","firestore","hostId","entryId","ref","set","paths","createdAt","attempts","entireHost","isPermissionDenied","error","code","commitWithSiteWideEntryAs","commit","newEntryId","staged","entry","undefined","releaseSiteWideOutboxEntry","reason","catch"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,SAAS,EACTC,GAAG,EACHC,eAAe,QAGV,qBAAoB;AAE3B;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GAED;;;;CAIC,GACD,OAAO,MAAMC,8BAA8B,gBAAe;AAE1D;;;;CAIC,GACD,OAAO,MAAMC,yBAA4C;IAAC;CAAI,CAAA;AAU9D;;;CAGC,GACD,OAAO,SAASC,2BACdC,KAA0B,EAC1BC,SAAoB,EACpBC,MAAc,EACdC,OAAe;IAEf,MAAMC,MAAMT,IAAIM,WAAWJ,6BAA6BM;IACxDH,MAAMK,GAAG,CAACD,KAAK;QACbF;QACAI,OAAO;eAAIR;SAAuB;QAClC,wEAAwE;QACxE,sBAAsB;QACtBS,WAAWX;QACXY,UAAU;QACVC,YAAY;IACd;IACA,OAAOL;AACT;AAQA,MAAMM,qBAAqB,CAACC,QAC1B,CAACA,yBAAD,AAACA,MAAqCC,IAAI,MAAK;AAEjD;;;;;;;;;;;;;;;CAeC,GACD,OAAO,eAAeC,0BACpBX,MAAc,EACdY,MAAgE,EAChEC,UAAwB;IAExB,yEAAyE;IACzE,2CAA2C;IAC3C,MAAMC,SAA8C;QAAEC,OAAO;IAAK;IAClE,IAAI;QACF,MAAMH,OAAO,CAACd,OAAOC;YACnBe,OAAOC,KAAK,GAAGlB,2BAA2BC,OAAOC,WAAWC,QAAQa;QACtE;QACA,OAAOC,OAAOC,KAAK;IACrB,EAAE,OAAON,OAAO;QACd,IAAI,CAACK,OAAOC,KAAK,IAAI,CAACP,mBAAmBC,QAAQ,MAAMA;QACvD,MAAMG,OAAOI;QACb,OAAO;IACT;AACF;AAEA;;;;;;CAMC,GACD,OAAO,eAAeC,2BACpBF,KAA+B,EAC/BG,MAAiC;IAEjC,IAAI,CAACH,SAASG,WAAW,MAAM;IAC/B,MAAM1B,UAAUuB,OAAOI,KAAK,CAAC,IAAYH;AAC3C"}
|
|
@@ -82,6 +82,12 @@ export interface TransferExportLaunch {
|
|
|
82
82
|
label: string;
|
|
83
83
|
value: unknown;
|
|
84
84
|
};
|
|
85
|
+
/**
|
|
86
|
+
* One of the resource's own presets to open on (`TransferResourcePreset.id`)
|
|
87
|
+
* — a list's "Export for Pirate Ship" — instead of the person's last
|
|
88
|
+
* choice. An id the resource does not offer is ignored.
|
|
89
|
+
*/
|
|
90
|
+
preset?: string;
|
|
85
91
|
}
|
|
86
92
|
/** What a person may do with a resource's records. */
|
|
87
93
|
export type TransferAction = 'import' | 'export';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/transfer-launcher-context.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 { createContext, useContext } from 'react'\nimport type { TransferUnfinishedImportSummary } from '../data-transfer/transfer-api'\n\n/**\n * The console shell's import wizard and export dialog, for a plugin to open.\n *\n * The wizard and the dialog are the console's private UI kit, which a\n * published plugin cannot depend on. So the shell hands down a launcher\n * through this context, and a plugin's list or hub opens the wizard for its\n * own resource — the key it declared in `transferResources` — with\n * `useTransferLauncher()?.openImport({ resource, scope })`.\n *\n * Outside the console shell — a spec, a tenant page, a surface mounted by\n * something else — the context holds `null`, and a plugin hides its Import\n * and Export actions: with no shell there is no workspace to move data into.\n *\n * Inside it, `can('import' | 'export', target)` says which of the two this\n * person may use (AGL-3546): a plugin shows Import only when `can('import')`\n * and Export only when `can('export')`. The transfer routes stay the\n * enforcement; `can` answers what they would.\n *\n * Only types and a context: it names no resource, imports nothing from the\n * transfer core, and so costs the barrel nothing past React.\n *\n * A client-only context like the others in `contexts.ts`: it calls\n * `createContext` at module scope, so it is reachable from the full\n * `@aglyn/aglyn` barrel (and this subpath) and never from\n * `@aglyn/aglyn/server` (AGL-405).\n */\n\n/** Where the records live: the workspace, or one of its sites. */\nexport type TransferLaunchScope = 'org' | 'host'\n\n/** Opens the import wizard on one resource. */\nexport interface TransferImportLaunch {\n /**\n * The resource key the plugin declared in `transferResources` — for one\n * declared with `instances`, the key naming the instance\n * (`data.dataset:<datasetId>`).\n */\n resource: string\n scope: TransferLaunchScope\n /** The wizard's title, naming the instance (\"Import into Products\"); \"Import <resource label>\" when absent. */\n title?: string\n /** The site, for a `host` resource. */\n hostId?: string | null\n /** Resume this import instead of starting a new one. */\n jobId?: string | null\n /**\n * What the file is imported as, as the `importMapping` zone's widgets know\n * it (`contacts`); omitted, the mapping step shows no zone.\n */\n mappingZone?: string\n /** Called when the person leaves the wizard from its results with Done. */\n onFinished?(): void\n}\n\n/** Opens the export dialog on one resource. */\nexport interface TransferExportLaunch {\n /** The resource key the plugin declared in `transferResources`, naming the instance for one declared with `instances`. */\n resource: string\n scope: TransferLaunchScope\n /** The dialog's title, naming the instance (\"Export Products\"); \"Export <resource label>\" when absent. */\n title?: string\n /** The site, for a `host` resource. */\n hostId?: string | null\n /** The record ids the person selected in the list, when there is a selection. */\n selection?: readonly string[]\n /** The list's current filter: a name for it, and the value the resource's `readPage` reads. */\n filter?: { label: string; value: unknown }\n}\n\n/** What a person may do with a resource's records. */\nexport type TransferAction = 'import' | 'export'\n\n/** The resource a list asks {@link TransferLauncher.can} about. */\nexport interface TransferAccessTarget {\n /** The resource key, naming the instance for one declared with `instances`. */\n resource: string\n scope: TransferLaunchScope\n /** The site, for a `host` resource or a workspace resource read through one site. */\n hostId?: string | null\n}\n\n/**\n * An import the person started and left before it wrote anything\n * (AGL-3549) — a tab closed mid-wizard — which `openImport({ jobId })`\n * reopens on the step it was left at.\n */\nexport type TransferUnfinishedImport = TransferUnfinishedImportSummary\n\n/** What the shell offers: open the wizard or the dialog, or close whichever is open. */\nexport interface TransferLauncher {\n openImport(launch: TransferImportLaunch): void\n openExport(launch: TransferExportLaunch): void\n close(): void\n /**\n * Whether the person may `action` the target's records (AGL-3546) — the\n * answer the transfer routes give, from the permissions the shell already\n * holds: synchronous, with no request per button, and `false` until those\n * permissions have answered. Importing needs \"Manage data\" (on the named\n * site, for a site's records); exporting needs only access to the\n * records — membership, and reaching the named site — unless the resource\n * declares a `readPermission`. The routes stay the enforcement.\n */\n can(action: TransferAction, target: TransferAccessTarget): boolean\n /**\n * The person's own unfinished imports (AGL-3549), newest first: of the\n * target's resource — the exact key, so one dataset's are not another's —\n * on the target's site when it names one, when a target is given; every\n * one otherwise. Empty\n * until the shell has read them; the first ask starts the read, and the\n * list is read again whenever the wizard closes. Only for a person who may\n * import into the workspace.\n */\n unfinished?(target?: TransferAccessTarget): readonly TransferUnfinishedImport[]\n /**\n * Throws an unfinished import away now — its job, its file and its saved\n * choices — instead of leaving it to expire. Refused for an import that\n * has written anything, which is undone instead.\n */\n discard?(jobId: string): Promise<void>\n}\n\nexport const TransferLauncherContext = createContext<TransferLauncher | null>(null)\nTransferLauncherContext.displayName = 'TransferLauncherContext'\n\n/** The shell's launcher, or `null` outside the console shell. */\nexport function useTransferLauncher(): TransferLauncher | null {\n return useContext(TransferLauncherContext)\n}\n\n"],"names":["createContext","useContext","TransferLauncherContext","displayName","useTransferLauncher"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,aAAa,EAAEC,UAAU,QAAQ,QAAO;
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/transfer-launcher-context.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 { createContext, useContext } from 'react'\nimport type { TransferUnfinishedImportSummary } from '../data-transfer/transfer-api'\n\n/**\n * The console shell's import wizard and export dialog, for a plugin to open.\n *\n * The wizard and the dialog are the console's private UI kit, which a\n * published plugin cannot depend on. So the shell hands down a launcher\n * through this context, and a plugin's list or hub opens the wizard for its\n * own resource — the key it declared in `transferResources` — with\n * `useTransferLauncher()?.openImport({ resource, scope })`.\n *\n * Outside the console shell — a spec, a tenant page, a surface mounted by\n * something else — the context holds `null`, and a plugin hides its Import\n * and Export actions: with no shell there is no workspace to move data into.\n *\n * Inside it, `can('import' | 'export', target)` says which of the two this\n * person may use (AGL-3546): a plugin shows Import only when `can('import')`\n * and Export only when `can('export')`. The transfer routes stay the\n * enforcement; `can` answers what they would.\n *\n * Only types and a context: it names no resource, imports nothing from the\n * transfer core, and so costs the barrel nothing past React.\n *\n * A client-only context like the others in `contexts.ts`: it calls\n * `createContext` at module scope, so it is reachable from the full\n * `@aglyn/aglyn` barrel (and this subpath) and never from\n * `@aglyn/aglyn/server` (AGL-405).\n */\n\n/** Where the records live: the workspace, or one of its sites. */\nexport type TransferLaunchScope = 'org' | 'host'\n\n/** Opens the import wizard on one resource. */\nexport interface TransferImportLaunch {\n /**\n * The resource key the plugin declared in `transferResources` — for one\n * declared with `instances`, the key naming the instance\n * (`data.dataset:<datasetId>`).\n */\n resource: string\n scope: TransferLaunchScope\n /** The wizard's title, naming the instance (\"Import into Products\"); \"Import <resource label>\" when absent. */\n title?: string\n /** The site, for a `host` resource. */\n hostId?: string | null\n /** Resume this import instead of starting a new one. */\n jobId?: string | null\n /**\n * What the file is imported as, as the `importMapping` zone's widgets know\n * it (`contacts`); omitted, the mapping step shows no zone.\n */\n mappingZone?: string\n /** Called when the person leaves the wizard from its results with Done. */\n onFinished?(): void\n}\n\n/** Opens the export dialog on one resource. */\nexport interface TransferExportLaunch {\n /** The resource key the plugin declared in `transferResources`, naming the instance for one declared with `instances`. */\n resource: string\n scope: TransferLaunchScope\n /** The dialog's title, naming the instance (\"Export Products\"); \"Export <resource label>\" when absent. */\n title?: string\n /** The site, for a `host` resource. */\n hostId?: string | null\n /** The record ids the person selected in the list, when there is a selection. */\n selection?: readonly string[]\n /** The list's current filter: a name for it, and the value the resource's `readPage` reads. */\n filter?: { label: string; value: unknown }\n /**\n * One of the resource's own presets to open on (`TransferResourcePreset.id`)\n * — a list's \"Export for Pirate Ship\" — instead of the person's last\n * choice. An id the resource does not offer is ignored.\n */\n preset?: string\n}\n\n/** What a person may do with a resource's records. */\nexport type TransferAction = 'import' | 'export'\n\n/** The resource a list asks {@link TransferLauncher.can} about. */\nexport interface TransferAccessTarget {\n /** The resource key, naming the instance for one declared with `instances`. */\n resource: string\n scope: TransferLaunchScope\n /** The site, for a `host` resource or a workspace resource read through one site. */\n hostId?: string | null\n}\n\n/**\n * An import the person started and left before it wrote anything\n * (AGL-3549) — a tab closed mid-wizard — which `openImport({ jobId })`\n * reopens on the step it was left at.\n */\nexport type TransferUnfinishedImport = TransferUnfinishedImportSummary\n\n/** What the shell offers: open the wizard or the dialog, or close whichever is open. */\nexport interface TransferLauncher {\n openImport(launch: TransferImportLaunch): void\n openExport(launch: TransferExportLaunch): void\n close(): void\n /**\n * Whether the person may `action` the target's records (AGL-3546) — the\n * answer the transfer routes give, from the permissions the shell already\n * holds: synchronous, with no request per button, and `false` until those\n * permissions have answered. Importing needs \"Manage data\" (on the named\n * site, for a site's records); exporting needs only access to the\n * records — membership, and reaching the named site — unless the resource\n * declares a `readPermission`. The routes stay the enforcement.\n */\n can(action: TransferAction, target: TransferAccessTarget): boolean\n /**\n * The person's own unfinished imports (AGL-3549), newest first: of the\n * target's resource — the exact key, so one dataset's are not another's —\n * on the target's site when it names one, when a target is given; every\n * one otherwise. Empty\n * until the shell has read them; the first ask starts the read, and the\n * list is read again whenever the wizard closes. Only for a person who may\n * import into the workspace.\n */\n unfinished?(target?: TransferAccessTarget): readonly TransferUnfinishedImport[]\n /**\n * Throws an unfinished import away now — its job, its file and its saved\n * choices — instead of leaving it to expire. Refused for an import that\n * has written anything, which is undone instead.\n */\n discard?(jobId: string): Promise<void>\n}\n\nexport const TransferLauncherContext = createContext<TransferLauncher | null>(null)\nTransferLauncherContext.displayName = 'TransferLauncherContext'\n\n/** The shell's launcher, or `null` outside the console shell. */\nexport function useTransferLauncher(): TransferLauncher | null {\n return useContext(TransferLauncherContext)\n}\n\n"],"names":["createContext","useContext","TransferLauncherContext","displayName","useTransferLauncher"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,aAAa,EAAEC,UAAU,QAAQ,QAAO;AAkIjD,OAAO,MAAMC,0BAA0BF,cAAuC,MAAK;AACnFE,wBAAwBC,WAAW,GAAG;AAEtC,+DAA+D,GAC/D,OAAO,SAASC;IACd,OAAOH,WAAWC;AACpB"}
|
|
@@ -417,6 +417,13 @@ const ascii = (text)=>Array.from(text, (character)=>character.charCodeAt(0));
|
|
|
417
417
|
],
|
|
418
418
|
label: 'Matroska/WebM'
|
|
419
419
|
}
|
|
420
|
+
],
|
|
421
|
+
// A web font (AGL-3656): the theme's font installer stores WOFF2 only.
|
|
422
|
+
'font/woff2': [
|
|
423
|
+
{
|
|
424
|
+
magic: ascii('wOF2'),
|
|
425
|
+
label: 'WOFF2'
|
|
426
|
+
}
|
|
420
427
|
]
|
|
421
428
|
};
|
|
422
429
|
/** The OOXML document types whose archive is scanned for a macro project. */ const OOXML_DOCUMENT_TYPES = new Set([
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/upload-inspection.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 * STRUCTURAL upload inspection (AGL-1475).\n *\n * ## What this is, and what it is emphatically not\n *\n * This is **not** an antivirus scanner and no code in this module should ever\n * be described as one. It does not have malware signatures, it does not\n * emulate, unpack or detonate anything, and a novel trojan with a correct PDF\n * header passes it without a murmur. It is *structural validation*: it reads\n * the first few kilobytes of an upload and answers three questions the\n * platform previously could not ask at all.\n *\n * 1. **Are these bytes an executable?** A Windows PE, an ELF binary, a Mach-O\n * binary or an installer package is refused whatever it claims to be. This\n * is the single highest-value check here: the Sept-1 risk is a customer's\n * domain re-serving a trojan to a visitor's browser, and the overwhelming\n * majority of that population is a plain executable with a lying name.\n * 2. **Do the bytes match the declared type?** Media ingress trusted the\n * caller's content type completely — AGL-2463 wrote that gap down. The\n * allowlist bounded what a file *claimed* to be and nothing bounded what it\n * *was*, so `Content-Type: application/pdf` over a `.exe` was stored,\n * hashed, given a CDN path and served. It now has to be a PDF.\n * 3. **Does an Office document carry a macro project?** AGL-1465 kept\n * `.docm`/`.xlsm`/`.pptm` out **by content type**, which is a gate on a\n * string the uploader chooses. Renaming a macro-enabled document to\n * `.docx` walked straight past it. The archive is now checked for a\n * `vbaProject.bin` entry, which is the thing that made those extensions\n * worth excluding in the first place.\n *\n * Plus one signature, EICAR — see {@link EICAR_TEST_SIGNATURE}.\n *\n * ## What it therefore does not catch\n *\n * A malicious PDF that is a real PDF. A macro-free document that exploits a\n * reader. Anything inside a plain `application/zip`, which is a brand kit and\n * may legitimately contain a build tool. An obfuscated dropper in a JSON\n * file. Real malware detection needs signatures and an engine, which needs a\n * ClamAV service, which does not fit the budget this platform runs on — the\n * cost work is in the issue. Nothing here should be allowed to read as though\n * that decision went the other way.\n *\n * ## Why it is pure, and takes a window rather than a file\n *\n * Three of the four media chokepoints hold the whole file in memory and can\n * pass it straight in. The fourth — signed direct-to-storage upload — never\n * sees the bytes, because that is the entire reason it exists: a 200 MB video\n * goes browser → bucket and downloading it back into the function to look at\n * it would cost more than the feature. So that caller does two RANGED reads\n * instead, a few KB from each end, and passes them as {@link\n * InspectUploadInput.bytes} and {@link InspectUploadInput.tail}. Every\n * signature this module knows lives in one of those two windows, so the\n * signed route gets the same verdict for the price of a rounding error.\n */\n\n/**\n * The EICAR standard antivirus test string.\n *\n * A 68-byte printable string, defined by the European Institute for Computer\n * Antivirus Research, that every antivirus product agrees to flag and that is\n * completely inert. It exists so that a pipeline can be tested end to end\n * without anyone handling a live sample.\n *\n * It is matched here for exactly two reasons, and neither is malware\n * detection. First, it makes this module's own tests honest without checking\n * a real sample into the repository. Second, it is the probe a security\n * reviewer or a customer will actually try, and answering it with silence\n * would be a worse lie than answering it with a refusal — a refusal that says\n * \"structural check\" is true, where a silent accept invites the reader to\n * conclude nothing is checked at all.\n *\n * **This is a signature list of one. It is not an engine.** Do not add a\n * second entry here and start calling the result malware scanning; if real\n * signatures are wanted, they belong in a scanner service, not in a string\n * constant.\n */\nexport const EICAR_TEST_SIGNATURE =\n 'X5O!P%@AP[4\\\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*'\n\n/**\n * How many leading bytes an inspection needs.\n *\n * Every magic number checked here sits in the first 16 bytes; the window is\n * far larger so that the EICAR match and the small-file macro scan have room,\n * and because the signed route's ranged GET costs the same for 4 KB as for\n * 64 bytes.\n */\nexport const UPLOAD_INSPECTION_HEAD_BYTES = 4096\n\n/**\n * How many trailing bytes an inspection wants for an Office document.\n *\n * A ZIP's central directory — the authoritative list of what is inside it —\n * lives at the end of the file. 64 KB covers the directory of any document\n * archive this platform accepts (the largest ceiling is a 50 MB `.pptx`, and\n * its directory is a few KB even with hundreds of embedded images). A\n * document whose directory is somehow larger than this window degrades to\n * \"macro not found\", which is the pre-existing behaviour, not a regression.\n */\nexport const UPLOAD_INSPECTION_TAIL_BYTES = 65536\n\nexport type UploadInspectionCode =\n | 'signature_match'\n | 'executable_bytes'\n | 'type_mismatch'\n | 'macro_payload'\n | 'empty_file'\n\nexport interface UploadInspectionRefusal {\n /** Stable machine code — for the API error body, logs and tests. */\n code: UploadInspectionCode\n /** What the bytes turned out to be, in words rather than hex. */\n detected: string\n /**\n * The sentence the uploader sees. Names the file and what was found, so\n * that a merchant who did nothing wrong can tell which of fifty dropped\n * files was the problem and why.\n */\n message: string\n}\n\nexport interface InspectUploadInput {\n /**\n * The whole file, or its leading {@link UPLOAD_INSPECTION_HEAD_BYTES}.\n * Callers holding the whole file pass it and omit `tail`.\n */\n bytes: Uint8Array\n /**\n * The file's trailing bytes, supplied ONLY by a caller that passed a head\n * window rather than the whole file. When absent, `bytes` is searched for\n * the trailing structures too — which is correct precisely because a caller\n * that omits it is one that handed over the entire file.\n */\n tail?: Uint8Array | null\n /** The type the upload claims to be, already normalized by the caller. */\n contentType: string\n /** For the message only. Never used to decide anything. */\n fileName?: string | null\n}\n\ninterface Signature {\n /** Bytes to match. */\n magic: readonly number[]\n /** Where they start. */\n offset?: number\n /** Human name for the refusal message. */\n label: string\n}\n\nconst at = (bytes: Uint8Array, signature: Signature): boolean => {\n const offset = signature.offset ?? 0\n if (bytes.length < offset + signature.magic.length) return false\n for (let i = 0; i < signature.magic.length; i++) {\n if (bytes[offset + i] !== signature.magic[i]) return false\n }\n return true\n}\n\nconst ascii = (text: string): number[] =>\n Array.from(text, (character) => character.charCodeAt(0))\n\n/**\n * Executable and installer containers, refused whatever the upload claims to\n * be. This list is deliberately about CONTAINERS, not content: each entry is\n * a format whose entire purpose is to be run, and none of them has any\n * business in a media library under any content type.\n *\n * `#!` is NOT here on purpose. A shebang is two characters that a legitimate\n * `text/plain` upload can genuinely open with, it is not executable in a\n * browser, and the type-mismatch check below already refuses it under every\n * binary type. Refusing it outright would buy nothing and cost real uploads.\n */\nconst EXECUTABLE_SIGNATURES: readonly Signature[] = [\n { magic: ascii('MZ'), label: 'a Windows executable' },\n { magic: [0x7f, 0x45, 0x4c, 0x46], label: 'a Linux executable (ELF)' },\n { magic: [0xfe, 0xed, 0xfa, 0xce], label: 'a macOS executable (Mach-O)' },\n { magic: [0xfe, 0xed, 0xfa, 0xcf], label: 'a macOS executable (Mach-O)' },\n { magic: [0xce, 0xfa, 0xed, 0xfe], label: 'a macOS executable (Mach-O)' },\n { magic: [0xcf, 0xfa, 0xed, 0xfe], label: 'a macOS executable (Mach-O)' },\n // 0xCAFEBABE is both a Java class file and a multi-architecture Mach-O\n // binary. Both are code; the label names the likelier one for a DAM.\n { magic: [0xca, 0xfe, 0xba, 0xbe], label: 'a compiled program' },\n { magic: ascii('!<arch>'), label: 'an installer package' },\n { magic: [0xed, 0xab, 0xee, 0xdb], label: 'an installer package (RPM)' },\n // Windows shortcut — a .lnk is a launcher, and a classic phishing payload.\n { magic: [0x4c, 0x00, 0x00, 0x00, 0x01, 0x14, 0x02, 0x00], label: 'a Windows shortcut' },\n]\n\n/** ZIP, in its three legal opening forms. Every OOXML document is one. */\nconst ZIP_SIGNATURES: readonly Signature[] = [\n { magic: [0x50, 0x4b, 0x03, 0x04], label: 'ZIP' },\n { magic: [0x50, 0x4b, 0x05, 0x06], label: 'ZIP' },\n { magic: [0x50, 0x4b, 0x07, 0x08], label: 'ZIP' },\n]\n\n/** Microsoft's pre-2007 container: legacy `.doc`, `.xls`, `.ppt` and `.msi`. */\nconst OLE_SIGNATURE: Signature = {\n magic: [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1],\n label: 'a legacy Office document',\n}\n\n/** ISO base media (`....ftyp`) — mp4, QuickTime, and the modern image codecs. */\nconst ISO_BMFF: Signature = { magic: ascii('ftyp'), offset: 4, label: 'ISO media' }\n\n/**\n * Accepted content type → the signatures its bytes may legally start with.\n *\n * A type ABSENT from this table is not checked for a match, and that is a\n * deliberate, load-bearing property rather than a gap to be filled with\n * guesses. `text/plain`, `text/csv`, `text/markdown`, `application/json` and\n * `image/svg+xml` are text: they have no magic number, any leading bytes are\n * legal, and inventing a heuristic for them would refuse real files. They are\n * still covered by the executable check above, which is the check that\n * matters for them — an `.exe` renamed `notes.txt` is refused; a CSV that is\n * merely unusual is not.\n */\nconst TYPE_SIGNATURES: Readonly<Record<string, readonly Signature[]>> = {\n 'application/pdf': [{ magic: ascii('%PDF-'), label: 'PDF' }],\n 'application/zip': ZIP_SIGNATURES,\n 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ZIP_SIGNATURES,\n 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ZIP_SIGNATURES,\n 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ZIP_SIGNATURES,\n // Word writes RTF under a `.doc` name often enough that refusing it would\n // be a real false positive, so both containers are legal for this type.\n 'application/msword': [OLE_SIGNATURE, { magic: ascii('{\\\\rtf'), label: 'RTF' }],\n 'application/vnd.ms-excel': [OLE_SIGNATURE],\n 'application/vnd.ms-powerpoint': [OLE_SIGNATURE],\n 'application/rtf': [{ magic: ascii('{\\\\rtf'), label: 'RTF' }],\n\n 'image/png': [{ magic: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], label: 'PNG' }],\n 'image/jpeg': [{ magic: [0xff, 0xd8, 0xff], label: 'JPEG' }],\n 'image/gif': [\n { magic: ascii('GIF87a'), label: 'GIF' },\n { magic: ascii('GIF89a'), label: 'GIF' },\n ],\n 'image/webp': [{ magic: ascii('RIFF'), label: 'WebP' }],\n 'image/bmp': [{ magic: ascii('BM'), label: 'BMP' }],\n 'image/tiff': [\n { magic: [0x49, 0x49, 0x2a, 0x00], label: 'TIFF' },\n { magic: [0x4d, 0x4d, 0x00, 0x2a], label: 'TIFF' },\n ],\n 'image/x-icon': [{ magic: [0x00, 0x00, 0x01, 0x00], label: 'icon' }],\n 'image/vnd.microsoft.icon': [{ magic: [0x00, 0x00, 0x01, 0x00], label: 'icon' }],\n 'image/avif': [ISO_BMFF],\n 'image/heic': [ISO_BMFF],\n 'image/heif': [ISO_BMFF],\n\n 'video/mp4': [ISO_BMFF],\n 'video/quicktime': [ISO_BMFF],\n 'video/webm': [{ magic: [0x1a, 0x45, 0xdf, 0xa3], label: 'Matroska/WebM' }],\n}\n\n/** The OOXML document types whose archive is scanned for a macro project. */\nconst OOXML_DOCUMENT_TYPES = new Set([\n 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n])\n\n/** The pre-2007 Office types whose OLE directory is scanned for the same. */\nconst LEGACY_OFFICE_TYPES = new Set([\n 'application/msword',\n 'application/vnd.ms-excel',\n 'application/vnd.ms-powerpoint',\n])\n\n/**\n * Does a caller holding only a head window need to fetch the tail as well?\n *\n * Only the macro scan reads the end of a file, and only for document\n * archives, so this is what lets the signed-upload route skip a second ranged\n * GET for the 200 MB videos that route exists to carry — which is every\n * object on it bar a handful of documents.\n */\nexport function uploadInspectionNeedsTail(contentType: string): boolean {\n return (\n OOXML_DOCUMENT_TYPES.has(contentType) || LEGACY_OFFICE_TYPES.has(contentType)\n )\n}\n\nconst indexOfBytes = (haystack: Uint8Array, needle: Uint8Array): number => {\n if (!needle.length || haystack.length < needle.length) return -1\n const last = haystack.length - needle.length\n outer: for (let i = 0; i <= last; i++) {\n for (let j = 0; j < needle.length; j++) {\n if (haystack[i + j] !== needle[j]) continue outer\n }\n return i\n }\n return -1\n}\n\nconst contains = (haystack: Uint8Array, needle: Uint8Array): boolean =>\n indexOfBytes(haystack, needle) >= 0\n\nconst EICAR_BYTES = new Uint8Array(ascii(EICAR_TEST_SIGNATURE))\n\n/** `vbaProject.bin` as an OOXML archive entry name. */\nconst VBA_ENTRY = new Uint8Array(ascii('vbaProject.bin'))\n\n/**\n * `_VBA_PROJECT` as it appears in an OLE directory, which stores stream names\n * as UTF-16LE. Matching the encoded form rather than the ASCII one is what\n * keeps this from firing on a document that merely contains the words.\n */\nconst OLE_VBA_STREAM = new Uint8Array(\n ascii('_VBA_PROJECT').flatMap((code) => [code, 0x00]),\n)\n\nconst describeFile = (fileName?: string | null): string => {\n const name = String(fileName ?? '').trim()\n return name ? `\"${name}\"` : 'This file'\n}\n\n/**\n * Inspect an upload's bytes against the type it claims to be.\n *\n * Returns `null` when nothing structural is wrong — which, to be explicit, is\n * NOT a statement that the file is safe. See the module header.\n */\nexport function inspectUploadBytes(\n input: InspectUploadInput,\n): UploadInspectionRefusal | null {\n const { bytes, contentType, fileName } = input\n const head = bytes ?? new Uint8Array(0)\n // A caller that passed the whole file gets the whole file searched for the\n // trailing structures too; only a ranged caller supplies a separate tail.\n const tail = input.tail ?? head\n const subject = describeFile(fileName)\n\n if (!head.length) {\n return {\n code: 'empty_file',\n detected: 'an empty file',\n message: `${subject} is empty, so there is nothing to store.`,\n }\n }\n\n // 1. The signature list of one. Checked first so that a probe gets the\n // answer it is actually asking for, whatever else is also wrong.\n if (contains(head, EICAR_BYTES) || contains(tail, EICAR_BYTES)) {\n return {\n code: 'signature_match',\n detected: 'the EICAR antivirus test file',\n message:\n `${subject} matches the EICAR test signature and was refused by the ` +\n `upload structure check.`,\n }\n }\n\n // 2. Executable containers, refused under every declared type.\n for (const signature of EXECUTABLE_SIGNATURES) {\n if (at(head, signature)) {\n return {\n code: 'executable_bytes',\n detected: signature.label,\n message:\n `${subject} contains ${signature.label}, which cannot be uploaded ` +\n `whatever it is named or labeled.`,\n }\n }\n }\n\n // 3. The declared type against what the bytes actually are.\n const expected = TYPE_SIGNATURES[contentType]\n if (expected && !expected.some((signature) => at(head, signature))) {\n return {\n code: 'type_mismatch',\n detected: `something other than ${expected[0].label}`,\n message:\n `${subject} is labeled ${contentType} but its contents are not ` +\n `${expected[0].label}. Re-save it in the format it claims to be, or ` +\n `upload it under its real type.`,\n }\n }\n\n // 4. A macro project inside a document whose type is not supposed to have\n // one. This is the check the content-type allowlist could never make.\n if (OOXML_DOCUMENT_TYPES.has(contentType)) {\n if (contains(head, VBA_ENTRY) || contains(tail, VBA_ENTRY)) {\n return {\n code: 'macro_payload',\n detected: 'an embedded macro project',\n message:\n `${subject} contains an embedded macro project. Macro-enabled ` +\n `documents are not accepted — save it without macros and upload ` +\n `it again.`,\n }\n }\n } else if (LEGACY_OFFICE_TYPES.has(contentType)) {\n if (contains(head, OLE_VBA_STREAM) || contains(tail, OLE_VBA_STREAM)) {\n return {\n code: 'macro_payload',\n detected: 'an embedded macro project',\n message:\n `${subject} contains an embedded macro project. Macro-enabled ` +\n `documents are not accepted — save it without macros and upload ` +\n `it again.`,\n }\n }\n }\n\n return null\n}\n\nexport default inspectUploadBytes\n"],"names":["EICAR_TEST_SIGNATURE","UPLOAD_INSPECTION_HEAD_BYTES","UPLOAD_INSPECTION_TAIL_BYTES","at","bytes","signature","offset","length","magic","i","ascii","text","Array","from","character","charCodeAt","EXECUTABLE_SIGNATURES","label","ZIP_SIGNATURES","OLE_SIGNATURE","ISO_BMFF","TYPE_SIGNATURES","OOXML_DOCUMENT_TYPES","Set","LEGACY_OFFICE_TYPES","uploadInspectionNeedsTail","contentType","has","indexOfBytes","haystack","needle","last","outer","j","contains","EICAR_BYTES","Uint8Array","VBA_ENTRY","OLE_VBA_STREAM","flatMap","code","describeFile","fileName","name","String","trim","inspectUploadBytes","input","head","tail","subject","detected","message","expected","some"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoDC,GAED;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,MAAMA,uBACX,wEAAuE;AAEzE;;;;;;;CAOC,GACD,OAAO,MAAMC,+BAA+B,KAAI;AAEhD;;;;;;;;;CASC,GACD,OAAO,MAAMC,+BAA+B,MAAK;AAkDjD,MAAMC,KAAK,CAACC,OAAmBC;QACdA;IAAf,MAAMC,UAASD,oBAAAA,UAAUC,MAAM,YAAhBD,oBAAoB;IACnC,IAAID,MAAMG,MAAM,GAAGD,SAASD,UAAUG,KAAK,CAACD,MAAM,EAAE,OAAO;IAC3D,IAAK,IAAIE,IAAI,GAAGA,IAAIJ,UAAUG,KAAK,CAACD,MAAM,EAAEE,IAAK;QAC/C,IAAIL,KAAK,CAACE,SAASG,EAAE,KAAKJ,UAAUG,KAAK,CAACC,EAAE,EAAE,OAAO;IACvD;IACA,OAAO;AACT;AAEA,MAAMC,QAAQ,CAACC,OACbC,MAAMC,IAAI,CAACF,MAAM,CAACG,YAAcA,UAAUC,UAAU,CAAC;AAEvD;;;;;;;;;;CAUC,GACD,MAAMC,wBAA8C;IAClD;QAAER,OAAOE,MAAM;QAAOO,OAAO;IAAuB;IACpD;QAAET,OAAO;YAAC;YAAM;YAAM;YAAM;SAAK;QAAES,OAAO;IAA2B;IACrE;QAAET,OAAO;YAAC;YAAM;YAAM;YAAM;SAAK;QAAES,OAAO;IAA8B;IACxE;QAAET,OAAO;YAAC;YAAM;YAAM;YAAM;SAAK;QAAES,OAAO;IAA8B;IACxE;QAAET,OAAO;YAAC;YAAM;YAAM;YAAM;SAAK;QAAES,OAAO;IAA8B;IACxE;QAAET,OAAO;YAAC;YAAM;YAAM;YAAM;SAAK;QAAES,OAAO;IAA8B;IACxE,uEAAuE;IACvE,qEAAqE;IACrE;QAAET,OAAO;YAAC;YAAM;YAAM;YAAM;SAAK;QAAES,OAAO;IAAqB;IAC/D;QAAET,OAAOE,MAAM;QAAYO,OAAO;IAAuB;IACzD;QAAET,OAAO;YAAC;YAAM;YAAM;YAAM;SAAK;QAAES,OAAO;IAA6B;IACvE,2EAA2E;IAC3E;QAAET,OAAO;YAAC;YAAM;YAAM;YAAM;YAAM;YAAM;YAAM;YAAM;SAAK;QAAES,OAAO;IAAqB;CACxF;AAED,wEAAwE,GACxE,MAAMC,iBAAuC;IAC3C;QAAEV,OAAO;YAAC;YAAM;YAAM;YAAM;SAAK;QAAES,OAAO;IAAM;IAChD;QAAET,OAAO;YAAC;YAAM;YAAM;YAAM;SAAK;QAAES,OAAO;IAAM;IAChD;QAAET,OAAO;YAAC;YAAM;YAAM;YAAM;SAAK;QAAES,OAAO;IAAM;CACjD;AAED,8EAA8E,GAC9E,MAAME,gBAA2B;IAC/BX,OAAO;QAAC;QAAM;QAAM;QAAM;QAAM;QAAM;QAAM;QAAM;KAAK;IACvDS,OAAO;AACT;AAEA,+EAA+E,GAC/E,MAAMG,WAAsB;IAAEZ,OAAOE,MAAM;IAASJ,QAAQ;IAAGW,OAAO;AAAY;AAElF;;;;;;;;;;;CAWC,GACD,MAAMI,kBAAkE;IACtE,mBAAmB;QAAC;YAAEb,OAAOE,MAAM;YAAUO,OAAO;QAAM;KAAE;IAC5D,mBAAmBC;IACnB,2EAA2EA;IAC3E,qEAAqEA;IACrE,6EAA6EA;IAC7E,0EAA0E;IAC1E,wEAAwE;IACxE,sBAAsB;QAACC;QAAe;YAAEX,OAAOE,MAAM;YAAWO,OAAO;QAAM;KAAE;IAC/E,4BAA4B;QAACE;KAAc;IAC3C,iCAAiC;QAACA;KAAc;IAChD,mBAAmB;QAAC;YAAEX,OAAOE,MAAM;YAAWO,OAAO;QAAM;KAAE;IAE7D,aAAa;QAAC;YAAET,OAAO;gBAAC;gBAAM;gBAAM;gBAAM;gBAAM;gBAAM;gBAAM;gBAAM;aAAK;YAAES,OAAO;QAAM;KAAE;IACxF,cAAc;QAAC;YAAET,OAAO;gBAAC;gBAAM;gBAAM;aAAK;YAAES,OAAO;QAAO;KAAE;IAC5D,aAAa;QACX;YAAET,OAAOE,MAAM;YAAWO,OAAO;QAAM;QACvC;YAAET,OAAOE,MAAM;YAAWO,OAAO;QAAM;KACxC;IACD,cAAc;QAAC;YAAET,OAAOE,MAAM;YAASO,OAAO;QAAO;KAAE;IACvD,aAAa;QAAC;YAAET,OAAOE,MAAM;YAAOO,OAAO;QAAM;KAAE;IACnD,cAAc;QACZ;YAAET,OAAO;gBAAC;gBAAM;gBAAM;gBAAM;aAAK;YAAES,OAAO;QAAO;QACjD;YAAET,OAAO;gBAAC;gBAAM;gBAAM;gBAAM;aAAK;YAAES,OAAO;QAAO;KAClD;IACD,gBAAgB;QAAC;YAAET,OAAO;gBAAC;gBAAM;gBAAM;gBAAM;aAAK;YAAES,OAAO;QAAO;KAAE;IACpE,4BAA4B;QAAC;YAAET,OAAO;gBAAC;gBAAM;gBAAM;gBAAM;aAAK;YAAES,OAAO;QAAO;KAAE;IAChF,cAAc;QAACG;KAAS;IACxB,cAAc;QAACA;KAAS;IACxB,cAAc;QAACA;KAAS;IAExB,aAAa;QAACA;KAAS;IACvB,mBAAmB;QAACA;KAAS;IAC7B,cAAc;QAAC;YAAEZ,OAAO;gBAAC;gBAAM;gBAAM;gBAAM;aAAK;YAAES,OAAO;QAAgB;KAAE;AAC7E;AAEA,2EAA2E,GAC3E,MAAMK,uBAAuB,IAAIC,IAAI;IACnC;IACA;IACA;CACD;AAED,2EAA2E,GAC3E,MAAMC,sBAAsB,IAAID,IAAI;IAClC;IACA;IACA;CACD;AAED;;;;;;;CAOC,GACD,OAAO,SAASE,0BAA0BC,WAAmB;IAC3D,OACEJ,qBAAqBK,GAAG,CAACD,gBAAgBF,oBAAoBG,GAAG,CAACD;AAErE;AAEA,MAAME,eAAe,CAACC,UAAsBC;IAC1C,IAAI,CAACA,OAAOvB,MAAM,IAAIsB,SAAStB,MAAM,GAAGuB,OAAOvB,MAAM,EAAE,OAAO,CAAC;IAC/D,MAAMwB,OAAOF,SAAStB,MAAM,GAAGuB,OAAOvB,MAAM;IAC5CyB,OAAO,IAAK,IAAIvB,IAAI,GAAGA,KAAKsB,MAAMtB,IAAK;QACrC,IAAK,IAAIwB,IAAI,GAAGA,IAAIH,OAAOvB,MAAM,EAAE0B,IAAK;YACtC,IAAIJ,QAAQ,CAACpB,IAAIwB,EAAE,KAAKH,MAAM,CAACG,EAAE,EAAE,SAASD;QAC9C;QACA,OAAOvB;IACT;IACA,OAAO,CAAC;AACV;AAEA,MAAMyB,WAAW,CAACL,UAAsBC,SACtCF,aAAaC,UAAUC,WAAW;AAEpC,MAAMK,cAAc,IAAIC,WAAW1B,MAAMV;AAEzC,qDAAqD,GACrD,MAAMqC,YAAY,IAAID,WAAW1B,MAAM;AAEvC;;;;CAIC,GACD,MAAM4B,iBAAiB,IAAIF,WACzB1B,MAAM,gBAAgB6B,OAAO,CAAC,CAACC,OAAS;QAACA;QAAM;KAAK;AAGtD,MAAMC,eAAe,CAACC;IACpB,MAAMC,OAAOC,OAAOF,mBAAAA,WAAY,IAAIG,IAAI;IACxC,OAAOF,OAAO,CAAC,CAAC,EAAEA,KAAK,CAAC,CAAC,GAAG;AAC9B;AAEA;;;;;CAKC,GACD,OAAO,SAASG,mBACdC,KAAyB;QAMZA;IAJb,MAAM,EAAE3C,KAAK,EAAEsB,WAAW,EAAEgB,QAAQ,EAAE,GAAGK;IACzC,MAAMC,OAAO5C,gBAAAA,QAAS,IAAIgC,WAAW;IACrC,2EAA2E;IAC3E,0EAA0E;IAC1E,MAAMa,QAAOF,cAAAA,MAAME,IAAI,YAAVF,cAAcC;IAC3B,MAAME,UAAUT,aAAaC;IAE7B,IAAI,CAACM,KAAKzC,MAAM,EAAE;QAChB,OAAO;YACLiC,MAAM;YACNW,UAAU;YACVC,SAAS,GAAGF,QAAQ,wCAAwC,CAAC;QAC/D;IACF;IAEA,uEAAuE;IACvE,oEAAoE;IACpE,IAAIhB,SAASc,MAAMb,gBAAgBD,SAASe,MAAMd,cAAc;QAC9D,OAAO;YACLK,MAAM;YACNW,UAAU;YACVC,SACE,GAAGF,QAAQ,yDAAyD,CAAC,GACrE,CAAC,uBAAuB,CAAC;QAC7B;IACF;IAEA,+DAA+D;IAC/D,KAAK,MAAM7C,aAAaW,sBAAuB;QAC7C,IAAIb,GAAG6C,MAAM3C,YAAY;YACvB,OAAO;gBACLmC,MAAM;gBACNW,UAAU9C,UAAUY,KAAK;gBACzBmC,SACE,GAAGF,QAAQ,UAAU,EAAE7C,UAAUY,KAAK,CAAC,2BAA2B,CAAC,GACnE,CAAC,gCAAgC,CAAC;YACtC;QACF;IACF;IAEA,4DAA4D;IAC5D,MAAMoC,WAAWhC,eAAe,CAACK,YAAY;IAC7C,IAAI2B,YAAY,CAACA,SAASC,IAAI,CAAC,CAACjD,YAAcF,GAAG6C,MAAM3C,aAAa;QAClE,OAAO;YACLmC,MAAM;YACNW,UAAU,CAAC,qBAAqB,EAAEE,QAAQ,CAAC,EAAE,CAACpC,KAAK,EAAE;YACrDmC,SACE,GAAGF,QAAQ,YAAY,EAAExB,YAAY,0BAA0B,CAAC,GAChE,GAAG2B,QAAQ,CAAC,EAAE,CAACpC,KAAK,CAAC,+CAA+C,CAAC,GACrE,CAAC,8BAA8B,CAAC;QACpC;IACF;IAEA,0EAA0E;IAC1E,yEAAyE;IACzE,IAAIK,qBAAqBK,GAAG,CAACD,cAAc;QACzC,IAAIQ,SAASc,MAAMX,cAAcH,SAASe,MAAMZ,YAAY;YAC1D,OAAO;gBACLG,MAAM;gBACNW,UAAU;gBACVC,SACE,GAAGF,QAAQ,mDAAmD,CAAC,GAC/D,CAAC,+DAA+D,CAAC,GACjE,CAAC,SAAS,CAAC;YACf;QACF;IACF,OAAO,IAAI1B,oBAAoBG,GAAG,CAACD,cAAc;QAC/C,IAAIQ,SAASc,MAAMV,mBAAmBJ,SAASe,MAAMX,iBAAiB;YACpE,OAAO;gBACLE,MAAM;gBACNW,UAAU;gBACVC,SACE,GAAGF,QAAQ,mDAAmD,CAAC,GAC/D,CAAC,+DAA+D,CAAC,GACjE,CAAC,SAAS,CAAC;YACf;QACF;IACF;IAEA,OAAO;AACT;AAEA,eAAeJ,mBAAkB"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/upload-inspection.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 * STRUCTURAL upload inspection (AGL-1475).\n *\n * ## What this is, and what it is emphatically not\n *\n * This is **not** an antivirus scanner and no code in this module should ever\n * be described as one. It does not have malware signatures, it does not\n * emulate, unpack or detonate anything, and a novel trojan with a correct PDF\n * header passes it without a murmur. It is *structural validation*: it reads\n * the first few kilobytes of an upload and answers three questions the\n * platform previously could not ask at all.\n *\n * 1. **Are these bytes an executable?** A Windows PE, an ELF binary, a Mach-O\n * binary or an installer package is refused whatever it claims to be. This\n * is the single highest-value check here: the Sept-1 risk is a customer's\n * domain re-serving a trojan to a visitor's browser, and the overwhelming\n * majority of that population is a plain executable with a lying name.\n * 2. **Do the bytes match the declared type?** Media ingress trusted the\n * caller's content type completely — AGL-2463 wrote that gap down. The\n * allowlist bounded what a file *claimed* to be and nothing bounded what it\n * *was*, so `Content-Type: application/pdf` over a `.exe` was stored,\n * hashed, given a CDN path and served. It now has to be a PDF.\n * 3. **Does an Office document carry a macro project?** AGL-1465 kept\n * `.docm`/`.xlsm`/`.pptm` out **by content type**, which is a gate on a\n * string the uploader chooses. Renaming a macro-enabled document to\n * `.docx` walked straight past it. The archive is now checked for a\n * `vbaProject.bin` entry, which is the thing that made those extensions\n * worth excluding in the first place.\n *\n * Plus one signature, EICAR — see {@link EICAR_TEST_SIGNATURE}.\n *\n * ## What it therefore does not catch\n *\n * A malicious PDF that is a real PDF. A macro-free document that exploits a\n * reader. Anything inside a plain `application/zip`, which is a brand kit and\n * may legitimately contain a build tool. An obfuscated dropper in a JSON\n * file. Real malware detection needs signatures and an engine, which needs a\n * ClamAV service, which does not fit the budget this platform runs on — the\n * cost work is in the issue. Nothing here should be allowed to read as though\n * that decision went the other way.\n *\n * ## Why it is pure, and takes a window rather than a file\n *\n * Three of the four media chokepoints hold the whole file in memory and can\n * pass it straight in. The fourth — signed direct-to-storage upload — never\n * sees the bytes, because that is the entire reason it exists: a 200 MB video\n * goes browser → bucket and downloading it back into the function to look at\n * it would cost more than the feature. So that caller does two RANGED reads\n * instead, a few KB from each end, and passes them as {@link\n * InspectUploadInput.bytes} and {@link InspectUploadInput.tail}. Every\n * signature this module knows lives in one of those two windows, so the\n * signed route gets the same verdict for the price of a rounding error.\n */\n\n/**\n * The EICAR standard antivirus test string.\n *\n * A 68-byte printable string, defined by the European Institute for Computer\n * Antivirus Research, that every antivirus product agrees to flag and that is\n * completely inert. It exists so that a pipeline can be tested end to end\n * without anyone handling a live sample.\n *\n * It is matched here for exactly two reasons, and neither is malware\n * detection. First, it makes this module's own tests honest without checking\n * a real sample into the repository. Second, it is the probe a security\n * reviewer or a customer will actually try, and answering it with silence\n * would be a worse lie than answering it with a refusal — a refusal that says\n * \"structural check\" is true, where a silent accept invites the reader to\n * conclude nothing is checked at all.\n *\n * **This is a signature list of one. It is not an engine.** Do not add a\n * second entry here and start calling the result malware scanning; if real\n * signatures are wanted, they belong in a scanner service, not in a string\n * constant.\n */\nexport const EICAR_TEST_SIGNATURE =\n 'X5O!P%@AP[4\\\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*'\n\n/**\n * How many leading bytes an inspection needs.\n *\n * Every magic number checked here sits in the first 16 bytes; the window is\n * far larger so that the EICAR match and the small-file macro scan have room,\n * and because the signed route's ranged GET costs the same for 4 KB as for\n * 64 bytes.\n */\nexport const UPLOAD_INSPECTION_HEAD_BYTES = 4096\n\n/**\n * How many trailing bytes an inspection wants for an Office document.\n *\n * A ZIP's central directory — the authoritative list of what is inside it —\n * lives at the end of the file. 64 KB covers the directory of any document\n * archive this platform accepts (the largest ceiling is a 50 MB `.pptx`, and\n * its directory is a few KB even with hundreds of embedded images). A\n * document whose directory is somehow larger than this window degrades to\n * \"macro not found\", which is the pre-existing behaviour, not a regression.\n */\nexport const UPLOAD_INSPECTION_TAIL_BYTES = 65536\n\nexport type UploadInspectionCode =\n | 'signature_match'\n | 'executable_bytes'\n | 'type_mismatch'\n | 'macro_payload'\n | 'empty_file'\n\nexport interface UploadInspectionRefusal {\n /** Stable machine code — for the API error body, logs and tests. */\n code: UploadInspectionCode\n /** What the bytes turned out to be, in words rather than hex. */\n detected: string\n /**\n * The sentence the uploader sees. Names the file and what was found, so\n * that a merchant who did nothing wrong can tell which of fifty dropped\n * files was the problem and why.\n */\n message: string\n}\n\nexport interface InspectUploadInput {\n /**\n * The whole file, or its leading {@link UPLOAD_INSPECTION_HEAD_BYTES}.\n * Callers holding the whole file pass it and omit `tail`.\n */\n bytes: Uint8Array\n /**\n * The file's trailing bytes, supplied ONLY by a caller that passed a head\n * window rather than the whole file. When absent, `bytes` is searched for\n * the trailing structures too — which is correct precisely because a caller\n * that omits it is one that handed over the entire file.\n */\n tail?: Uint8Array | null\n /** The type the upload claims to be, already normalized by the caller. */\n contentType: string\n /** For the message only. Never used to decide anything. */\n fileName?: string | null\n}\n\ninterface Signature {\n /** Bytes to match. */\n magic: readonly number[]\n /** Where they start. */\n offset?: number\n /** Human name for the refusal message. */\n label: string\n}\n\nconst at = (bytes: Uint8Array, signature: Signature): boolean => {\n const offset = signature.offset ?? 0\n if (bytes.length < offset + signature.magic.length) return false\n for (let i = 0; i < signature.magic.length; i++) {\n if (bytes[offset + i] !== signature.magic[i]) return false\n }\n return true\n}\n\nconst ascii = (text: string): number[] =>\n Array.from(text, (character) => character.charCodeAt(0))\n\n/**\n * Executable and installer containers, refused whatever the upload claims to\n * be. This list is deliberately about CONTAINERS, not content: each entry is\n * a format whose entire purpose is to be run, and none of them has any\n * business in a media library under any content type.\n *\n * `#!` is NOT here on purpose. A shebang is two characters that a legitimate\n * `text/plain` upload can genuinely open with, it is not executable in a\n * browser, and the type-mismatch check below already refuses it under every\n * binary type. Refusing it outright would buy nothing and cost real uploads.\n */\nconst EXECUTABLE_SIGNATURES: readonly Signature[] = [\n { magic: ascii('MZ'), label: 'a Windows executable' },\n { magic: [0x7f, 0x45, 0x4c, 0x46], label: 'a Linux executable (ELF)' },\n { magic: [0xfe, 0xed, 0xfa, 0xce], label: 'a macOS executable (Mach-O)' },\n { magic: [0xfe, 0xed, 0xfa, 0xcf], label: 'a macOS executable (Mach-O)' },\n { magic: [0xce, 0xfa, 0xed, 0xfe], label: 'a macOS executable (Mach-O)' },\n { magic: [0xcf, 0xfa, 0xed, 0xfe], label: 'a macOS executable (Mach-O)' },\n // 0xCAFEBABE is both a Java class file and a multi-architecture Mach-O\n // binary. Both are code; the label names the likelier one for a DAM.\n { magic: [0xca, 0xfe, 0xba, 0xbe], label: 'a compiled program' },\n { magic: ascii('!<arch>'), label: 'an installer package' },\n { magic: [0xed, 0xab, 0xee, 0xdb], label: 'an installer package (RPM)' },\n // Windows shortcut — a .lnk is a launcher, and a classic phishing payload.\n { magic: [0x4c, 0x00, 0x00, 0x00, 0x01, 0x14, 0x02, 0x00], label: 'a Windows shortcut' },\n]\n\n/** ZIP, in its three legal opening forms. Every OOXML document is one. */\nconst ZIP_SIGNATURES: readonly Signature[] = [\n { magic: [0x50, 0x4b, 0x03, 0x04], label: 'ZIP' },\n { magic: [0x50, 0x4b, 0x05, 0x06], label: 'ZIP' },\n { magic: [0x50, 0x4b, 0x07, 0x08], label: 'ZIP' },\n]\n\n/** Microsoft's pre-2007 container: legacy `.doc`, `.xls`, `.ppt` and `.msi`. */\nconst OLE_SIGNATURE: Signature = {\n magic: [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1],\n label: 'a legacy Office document',\n}\n\n/** ISO base media (`....ftyp`) — mp4, QuickTime, and the modern image codecs. */\nconst ISO_BMFF: Signature = { magic: ascii('ftyp'), offset: 4, label: 'ISO media' }\n\n/**\n * Accepted content type → the signatures its bytes may legally start with.\n *\n * A type ABSENT from this table is not checked for a match, and that is a\n * deliberate, load-bearing property rather than a gap to be filled with\n * guesses. `text/plain`, `text/csv`, `text/markdown`, `application/json` and\n * `image/svg+xml` are text: they have no magic number, any leading bytes are\n * legal, and inventing a heuristic for them would refuse real files. They are\n * still covered by the executable check above, which is the check that\n * matters for them — an `.exe` renamed `notes.txt` is refused; a CSV that is\n * merely unusual is not.\n */\nconst TYPE_SIGNATURES: Readonly<Record<string, readonly Signature[]>> = {\n 'application/pdf': [{ magic: ascii('%PDF-'), label: 'PDF' }],\n 'application/zip': ZIP_SIGNATURES,\n 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ZIP_SIGNATURES,\n 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ZIP_SIGNATURES,\n 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ZIP_SIGNATURES,\n // Word writes RTF under a `.doc` name often enough that refusing it would\n // be a real false positive, so both containers are legal for this type.\n 'application/msword': [OLE_SIGNATURE, { magic: ascii('{\\\\rtf'), label: 'RTF' }],\n 'application/vnd.ms-excel': [OLE_SIGNATURE],\n 'application/vnd.ms-powerpoint': [OLE_SIGNATURE],\n 'application/rtf': [{ magic: ascii('{\\\\rtf'), label: 'RTF' }],\n\n 'image/png': [{ magic: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], label: 'PNG' }],\n 'image/jpeg': [{ magic: [0xff, 0xd8, 0xff], label: 'JPEG' }],\n 'image/gif': [\n { magic: ascii('GIF87a'), label: 'GIF' },\n { magic: ascii('GIF89a'), label: 'GIF' },\n ],\n 'image/webp': [{ magic: ascii('RIFF'), label: 'WebP' }],\n 'image/bmp': [{ magic: ascii('BM'), label: 'BMP' }],\n 'image/tiff': [\n { magic: [0x49, 0x49, 0x2a, 0x00], label: 'TIFF' },\n { magic: [0x4d, 0x4d, 0x00, 0x2a], label: 'TIFF' },\n ],\n 'image/x-icon': [{ magic: [0x00, 0x00, 0x01, 0x00], label: 'icon' }],\n 'image/vnd.microsoft.icon': [{ magic: [0x00, 0x00, 0x01, 0x00], label: 'icon' }],\n 'image/avif': [ISO_BMFF],\n 'image/heic': [ISO_BMFF],\n 'image/heif': [ISO_BMFF],\n\n 'video/mp4': [ISO_BMFF],\n 'video/quicktime': [ISO_BMFF],\n 'video/webm': [{ magic: [0x1a, 0x45, 0xdf, 0xa3], label: 'Matroska/WebM' }],\n\n // A web font (AGL-3656): the theme's font installer stores WOFF2 only.\n 'font/woff2': [{ magic: ascii('wOF2'), label: 'WOFF2' }],\n}\n\n/** The OOXML document types whose archive is scanned for a macro project. */\nconst OOXML_DOCUMENT_TYPES = new Set([\n 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n])\n\n/** The pre-2007 Office types whose OLE directory is scanned for the same. */\nconst LEGACY_OFFICE_TYPES = new Set([\n 'application/msword',\n 'application/vnd.ms-excel',\n 'application/vnd.ms-powerpoint',\n])\n\n/**\n * Does a caller holding only a head window need to fetch the tail as well?\n *\n * Only the macro scan reads the end of a file, and only for document\n * archives, so this is what lets the signed-upload route skip a second ranged\n * GET for the 200 MB videos that route exists to carry — which is every\n * object on it bar a handful of documents.\n */\nexport function uploadInspectionNeedsTail(contentType: string): boolean {\n return (\n OOXML_DOCUMENT_TYPES.has(contentType) || LEGACY_OFFICE_TYPES.has(contentType)\n )\n}\n\nconst indexOfBytes = (haystack: Uint8Array, needle: Uint8Array): number => {\n if (!needle.length || haystack.length < needle.length) return -1\n const last = haystack.length - needle.length\n outer: for (let i = 0; i <= last; i++) {\n for (let j = 0; j < needle.length; j++) {\n if (haystack[i + j] !== needle[j]) continue outer\n }\n return i\n }\n return -1\n}\n\nconst contains = (haystack: Uint8Array, needle: Uint8Array): boolean =>\n indexOfBytes(haystack, needle) >= 0\n\nconst EICAR_BYTES = new Uint8Array(ascii(EICAR_TEST_SIGNATURE))\n\n/** `vbaProject.bin` as an OOXML archive entry name. */\nconst VBA_ENTRY = new Uint8Array(ascii('vbaProject.bin'))\n\n/**\n * `_VBA_PROJECT` as it appears in an OLE directory, which stores stream names\n * as UTF-16LE. Matching the encoded form rather than the ASCII one is what\n * keeps this from firing on a document that merely contains the words.\n */\nconst OLE_VBA_STREAM = new Uint8Array(\n ascii('_VBA_PROJECT').flatMap((code) => [code, 0x00]),\n)\n\nconst describeFile = (fileName?: string | null): string => {\n const name = String(fileName ?? '').trim()\n return name ? `\"${name}\"` : 'This file'\n}\n\n/**\n * Inspect an upload's bytes against the type it claims to be.\n *\n * Returns `null` when nothing structural is wrong — which, to be explicit, is\n * NOT a statement that the file is safe. See the module header.\n */\nexport function inspectUploadBytes(\n input: InspectUploadInput,\n): UploadInspectionRefusal | null {\n const { bytes, contentType, fileName } = input\n const head = bytes ?? new Uint8Array(0)\n // A caller that passed the whole file gets the whole file searched for the\n // trailing structures too; only a ranged caller supplies a separate tail.\n const tail = input.tail ?? head\n const subject = describeFile(fileName)\n\n if (!head.length) {\n return {\n code: 'empty_file',\n detected: 'an empty file',\n message: `${subject} is empty, so there is nothing to store.`,\n }\n }\n\n // 1. The signature list of one. Checked first so that a probe gets the\n // answer it is actually asking for, whatever else is also wrong.\n if (contains(head, EICAR_BYTES) || contains(tail, EICAR_BYTES)) {\n return {\n code: 'signature_match',\n detected: 'the EICAR antivirus test file',\n message:\n `${subject} matches the EICAR test signature and was refused by the ` +\n `upload structure check.`,\n }\n }\n\n // 2. Executable containers, refused under every declared type.\n for (const signature of EXECUTABLE_SIGNATURES) {\n if (at(head, signature)) {\n return {\n code: 'executable_bytes',\n detected: signature.label,\n message:\n `${subject} contains ${signature.label}, which cannot be uploaded ` +\n `whatever it is named or labeled.`,\n }\n }\n }\n\n // 3. The declared type against what the bytes actually are.\n const expected = TYPE_SIGNATURES[contentType]\n if (expected && !expected.some((signature) => at(head, signature))) {\n return {\n code: 'type_mismatch',\n detected: `something other than ${expected[0].label}`,\n message:\n `${subject} is labeled ${contentType} but its contents are not ` +\n `${expected[0].label}. Re-save it in the format it claims to be, or ` +\n `upload it under its real type.`,\n }\n }\n\n // 4. A macro project inside a document whose type is not supposed to have\n // one. This is the check the content-type allowlist could never make.\n if (OOXML_DOCUMENT_TYPES.has(contentType)) {\n if (contains(head, VBA_ENTRY) || contains(tail, VBA_ENTRY)) {\n return {\n code: 'macro_payload',\n detected: 'an embedded macro project',\n message:\n `${subject} contains an embedded macro project. Macro-enabled ` +\n `documents are not accepted — save it without macros and upload ` +\n `it again.`,\n }\n }\n } else if (LEGACY_OFFICE_TYPES.has(contentType)) {\n if (contains(head, OLE_VBA_STREAM) || contains(tail, OLE_VBA_STREAM)) {\n return {\n code: 'macro_payload',\n detected: 'an embedded macro project',\n message:\n `${subject} contains an embedded macro project. Macro-enabled ` +\n `documents are not accepted — save it without macros and upload ` +\n `it again.`,\n }\n }\n }\n\n return null\n}\n\nexport default inspectUploadBytes\n"],"names":["EICAR_TEST_SIGNATURE","UPLOAD_INSPECTION_HEAD_BYTES","UPLOAD_INSPECTION_TAIL_BYTES","at","bytes","signature","offset","length","magic","i","ascii","text","Array","from","character","charCodeAt","EXECUTABLE_SIGNATURES","label","ZIP_SIGNATURES","OLE_SIGNATURE","ISO_BMFF","TYPE_SIGNATURES","OOXML_DOCUMENT_TYPES","Set","LEGACY_OFFICE_TYPES","uploadInspectionNeedsTail","contentType","has","indexOfBytes","haystack","needle","last","outer","j","contains","EICAR_BYTES","Uint8Array","VBA_ENTRY","OLE_VBA_STREAM","flatMap","code","describeFile","fileName","name","String","trim","inspectUploadBytes","input","head","tail","subject","detected","message","expected","some"],"mappings":"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"}
|
|
@@ -109,7 +109,10 @@
|
|
|
109
109
|
'payment_method.detached',
|
|
110
110
|
// Fraud signals to staff (AGL-3356).
|
|
111
111
|
'radar.early_fraud_warning.created',
|
|
112
|
-
'review.opened'
|
|
112
|
+
'review.opened',
|
|
113
|
+
// Register card readers (AGL-3607).
|
|
114
|
+
'terminal.reader.action_succeeded',
|
|
115
|
+
'terminal.reader.action_failed'
|
|
113
116
|
];
|
|
114
117
|
/**
|
|
115
118
|
* How the CONNECT destination identifies itself (AGL-1948), mirroring
|