@aglyn/tenant-data-admin 1.0.0-beta.143 → 1.0.0-beta.144

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/README.md CHANGED
@@ -1,7 +1,87 @@
1
- # tenant-data-admin
1
+ # @aglyn/tenant-data-admin
2
2
 
3
- This library was generated with [Nx](https://nx.dev).
3
+ The server data layer of Aglyn's tenant runtime: the Firebase Admin setup,
4
+ typed Firestore converters, and the server-side modules that read and write
5
+ platform data with admin credentials. It is mainly an internal building block
6
+ of `@aglyn/tenant-runtime`, of Aglyn's server apps, and of the server half of
7
+ the `@aglyn/plugins-*` packages. Install it directly only if you are writing
8
+ server code against an Aglyn deployment's Firestore.
4
9
 
5
- ## Running unit tests
10
+ > Beta. Published from the Aglyn monorepo under the `beta` dist-tag; APIs can change between beta releases.
6
11
 
7
- Run `nx test tenant-data-admin` to execute the unit tests via [Jest](https://jestjs.io).
12
+ ## Install
13
+
14
+ npm install @aglyn/tenant-data-admin@beta
15
+
16
+ Peer dependencies:
17
+
18
+ - `firebase-admin`
19
+ - `next` (the render cache uses `next/cache`, and two request handlers use
20
+ Next's API route types)
21
+ - `react` (one module uses React's `cache`)
22
+
23
+ This package is server-only. It uses Node built-ins and admin credentials and
24
+ must not be imported from client code. It depends on `sharp` for image
25
+ variants.
26
+
27
+ ## What's in it
28
+
29
+ The root export is one barrel over `src/lib/server`. Its main areas:
30
+
31
+ - Firebase Admin: `firebaseAdmin`, Firestore converters for the core documents
32
+ (`hostConverter`, `screenConverter`, `screenVersionConverter`,
33
+ `layoutConverter`, `layoutVersionConverter`), and ID-token checks such as
34
+ `verifyConsoleIdToken` and `isEmailVerified`.
35
+ - Access: API keys (`mintApiKey`, `verifyApiKey`, `listApiKeys`,
36
+ `revokeApiKey`, `API_SCOPES`), host memberships, organizations, edit-access
37
+ tokens, auth handoff, SSO policy and provisioning, token revocation and
38
+ lockdown.
39
+ - Domains: workspace domains behind a `DomainProvider` seam
40
+ (`domainProvider`), console domains, DNS probing, and email sending domains.
41
+ - Email: suppression, unsubscribe links, topic confirmation, metering, the
42
+ send-rate governor, the marketing gate and sender reputation.
43
+ - Contacts and CRM records: contact upsert, merge, email index, company links,
44
+ list members and CRM activity records.
45
+ - Media: storage paths, signing, download tokens, variants, quarantine,
46
+ tombstones, delivery and the CDN request handler.
47
+ - Privacy: erasure (`erase`, `erase-person`) and personal data export.
48
+ - Abuse limits: rate-limit store, visitor write and console API rate limits,
49
+ password-reset, verify-email and membership-recovery throttles.
50
+ - Organization billing documents (`org-billing`), release flags, notifications, and log-drain
51
+ signature checks.
52
+
53
+ Importing the root has side effects, on purpose: evaluating
54
+ `email-send-rate` and `email-marketing-gate` installs the platform send-rate
55
+ governor and the marketing gate on the shared email sender, so every server
56
+ surface that imports this package gets them without a call.
57
+
58
+ By subpath:
59
+
60
+ - `@aglyn/tenant-data-admin/render-cache` has `withRenderCache`, the cache
61
+ tags (`tenantDataTag`, `tenantHostAliasTag`) and TTL constants used for
62
+ published site data.
63
+ - Any file under `src/lib`, for example
64
+ `@aglyn/tenant-data-admin/server/api-keys`, to import one module without the
65
+ barrel and its side effects.
66
+
67
+ ## Usage
68
+
69
+ ```ts
70
+ import { verifyApiKey } from '@aglyn/tenant-data-admin/server/api-keys'
71
+ import { withRenderCache } from '@aglyn/tenant-data-admin/render-cache'
72
+ ```
73
+
74
+ ```ts
75
+ import { firebaseAdmin, hostConverter } from '@aglyn/tenant-data-admin'
76
+ ```
77
+
78
+ ## How it fits
79
+
80
+ This is the data piece of the `tenant` scope in the package map. It imports
81
+ the core (`@aglyn/aglyn`) and generic `@aglyn/shared-*` utilities, and nothing
82
+ above it. `@aglyn/tenant-runtime` reads through it, and a plugin's server code
83
+ may import it; it never imports a plugin.
84
+
85
+ ## License
86
+
87
+ Apache-2.0. Source: https://github.com/aglyn/aglyn/tree/main/libs/tenant/data/admin
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/tenant-data-admin",
3
- "version": "1.0.0-beta.143",
3
+ "version": "1.0.0-beta.144",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,11 +25,11 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.143",
29
- "@aglyn/shared-util-email": "1.0.0-beta.143",
30
- "@aglyn/shared-util-fbserver": "1.0.0-beta.143",
31
- "@aglyn/shared-util-http": "1.0.0-beta.143",
32
- "@aglyn/shared-util-tools": "1.0.0-beta.143",
28
+ "@aglyn/aglyn": "1.0.0-beta.144",
29
+ "@aglyn/shared-util-email": "1.0.0-beta.144",
30
+ "@aglyn/shared-util-fbserver": "1.0.0-beta.144",
31
+ "@aglyn/shared-util-http": "1.0.0-beta.144",
32
+ "@aglyn/shared-util-tools": "1.0.0-beta.144",
33
33
  "@msgpack/msgpack": "^3.1.3",
34
34
  "@swc/helpers": "0.5.23",
35
35
  "sharp": "^0.35.3",
@@ -0,0 +1,122 @@
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
+ /**
18
+ * A capability to see ONE not-yet-live collection entry on the LIVE site
19
+ * (AGL-3205).
20
+ *
21
+ * The console is `app.aglyn.com`; a tenant is `aglyn.com`, `{sub}.aglyn.app`
22
+ * and every customer's own domain. A console session does not carry to any of
23
+ * them — different registrable domains, storage-partitioned — so "I am logged
24
+ * in, let me see the draft" cannot be answered by a session at all. What DOES
25
+ * carry is a URL, so the capability travels as a signed query parameter and is
26
+ * verified by the tenant server before it loads anything withheld.
27
+ *
28
+ * ## What the signature is over, and why each part is in it
29
+ *
30
+ * `{hostId}:{collectionSlug}:{entrySlug}:{exp}`, under the
31
+ * `collection-preview:` context prefix.
32
+ *
33
+ * - `hostId` — a token minted for one site must not work on another. The
34
+ * tenant compares it against the host it RESOLVED from the request, never
35
+ * against anything the URL says, so presenting `aglyn.com`'s token to
36
+ * another customer's domain fails on the host it actually reached.
37
+ * - `collectionSlug` + `entrySlug` — the scope is one post, not "previews are
38
+ * on for this site". The verifier is handed the slugs parsed from the route
39
+ * and refuses a mismatch, so a token for `/blog/post-a` cannot be edited
40
+ * into `/blog/post-b` or into `/news/post-a` and reveal either.
41
+ * - `exp` — inside the payload, so nobody without the secret can stretch it.
42
+ *
43
+ * ## The secret
44
+ *
45
+ * `TOKEN_SIGNING_SECRET`, the shared fail-closed secret the media, download,
46
+ * stream, edit-hint and form-binding signatures already use — for the reason
47
+ * `media-signing.ts` gives about not adding a second thing to keep in step
48
+ * across two apps. The `collection-preview:` prefix domain-separates it from
49
+ * every one of those, so none of them can be replayed as a preview grant and
50
+ * a preview grant cannot be replayed as any of them.
51
+ *
52
+ * ## What it is NOT
53
+ *
54
+ * It is not an edit session, it names no user, and it grants nothing but
55
+ * "render this one entry as though it were live". The render it authorizes
56
+ * writes nothing: a preview never publishes a due schedule and never records
57
+ * a refusal, because nothing but a PUBLIC render may move a content entry's
58
+ * state (see `flipDueEntry`).
59
+ */
60
+ /**
61
+ * Two hours.
62
+ *
63
+ * The request this was built for is "send me their preview links" — the link
64
+ * is meant to leave the console, ride a chat message and be opened on whatever
65
+ * device is to hand, so a window measured in minutes fails at the only job it
66
+ * has. Two hours covers a sitting: read three posts, come back after a call.
67
+ *
68
+ * It stays hours and not days because a URL is a bearer capability with no
69
+ * revocation, and because URLs are LOGGED — by the platform, by proxies, by
70
+ * whatever chat client carried it. The same property keeps the media
71
+ * signature at fifteen minutes, the gated-video session at four hours and the
72
+ * edit-hint bounce at sixty seconds. Past one sitting the right answer is to
73
+ * ask the console for another link, which re-checks that the person may still
74
+ * edit that site before it signs anything.
75
+ */
76
+ export declare const COLLECTION_PREVIEW_TTL_MS: number;
77
+ /**
78
+ * The longest lifetime any preview token may claim, enforced by the VERIFIER
79
+ * as well as the minter — the guard `MEDIA_SIGNATURE_MAX_TTL_MS` describes.
80
+ * `exp` is signed, so this bounds OUR mistakes rather than an attacker's: a
81
+ * minter that one day passes a day where it meant two hours hands out links
82
+ * nothing downstream would otherwise question.
83
+ */
84
+ export declare const COLLECTION_PREVIEW_MAX_TTL_MS: number;
85
+ export interface CollectionPreviewScope {
86
+ /** The host the entry belongs to — the resolved one, never a claimed one. */
87
+ hostId: string;
88
+ /** The collection's public slug, as the route spells it. */
89
+ collectionSlug: string;
90
+ /** The entry's public slug, as the route spells it. */
91
+ entrySlug: string;
92
+ }
93
+ export interface CollectionPreviewClaims extends CollectionPreviewScope {
94
+ /** Expiry, epoch ms. */
95
+ exp: number;
96
+ }
97
+ export interface MintedCollectionPreviewToken {
98
+ token: string;
99
+ expiresAtMs: number;
100
+ }
101
+ /**
102
+ * Mints a preview grant for a scope the caller has ALREADY authorized. The
103
+ * mint itself checks nothing but its own arguments — the console route around
104
+ * it proves the caller may edit this site's content before it calls.
105
+ */
106
+ export declare function mintCollectionPreviewToken(scope: CollectionPreviewScope, nowMs?: number): MintedCollectionPreviewToken;
107
+ /**
108
+ * Whether this presented token authorizes THIS scope right now.
109
+ *
110
+ * Answers a boolean rather than the claims, deliberately: the only thing a
111
+ * caller may do with a verified preview token is reveal the entry the ROUTE
112
+ * already named, so handing back a decoded `entrySlug` would invite a caller
113
+ * to trust the token's spelling over the route's and re-open the substitution
114
+ * this scoping exists to close.
115
+ *
116
+ * Refuses — with no distinction the caller can act on — a token that is
117
+ * malformed, the wrong version, tampered with, expired, claiming a lifetime
118
+ * past {@link COLLECTION_PREVIEW_MAX_TTL_MS}, minted for another host, or
119
+ * minted for another collection or entry. A deploy with no signing secret
120
+ * refuses too, like every signature in this family.
121
+ */
122
+ export declare function verifyCollectionPreviewToken(token: unknown, scope: CollectionPreviewScope, nowMs?: number): boolean;
@@ -0,0 +1,166 @@
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 { createHmac } from "crypto";
17
+ import { tokenSigningSecret } from "./media-signing.js";
18
+ import { safeEqual } from "./safe-equal.js";
19
+ /**
20
+ * A capability to see ONE not-yet-live collection entry on the LIVE site
21
+ * (AGL-3205).
22
+ *
23
+ * The console is `app.aglyn.com`; a tenant is `aglyn.com`, `{sub}.aglyn.app`
24
+ * and every customer's own domain. A console session does not carry to any of
25
+ * them — different registrable domains, storage-partitioned — so "I am logged
26
+ * in, let me see the draft" cannot be answered by a session at all. What DOES
27
+ * carry is a URL, so the capability travels as a signed query parameter and is
28
+ * verified by the tenant server before it loads anything withheld.
29
+ *
30
+ * ## What the signature is over, and why each part is in it
31
+ *
32
+ * `{hostId}:{collectionSlug}:{entrySlug}:{exp}`, under the
33
+ * `collection-preview:` context prefix.
34
+ *
35
+ * - `hostId` — a token minted for one site must not work on another. The
36
+ * tenant compares it against the host it RESOLVED from the request, never
37
+ * against anything the URL says, so presenting `aglyn.com`'s token to
38
+ * another customer's domain fails on the host it actually reached.
39
+ * - `collectionSlug` + `entrySlug` — the scope is one post, not "previews are
40
+ * on for this site". The verifier is handed the slugs parsed from the route
41
+ * and refuses a mismatch, so a token for `/blog/post-a` cannot be edited
42
+ * into `/blog/post-b` or into `/news/post-a` and reveal either.
43
+ * - `exp` — inside the payload, so nobody without the secret can stretch it.
44
+ *
45
+ * ## The secret
46
+ *
47
+ * `TOKEN_SIGNING_SECRET`, the shared fail-closed secret the media, download,
48
+ * stream, edit-hint and form-binding signatures already use — for the reason
49
+ * `media-signing.ts` gives about not adding a second thing to keep in step
50
+ * across two apps. The `collection-preview:` prefix domain-separates it from
51
+ * every one of those, so none of them can be replayed as a preview grant and
52
+ * a preview grant cannot be replayed as any of them.
53
+ *
54
+ * ## What it is NOT
55
+ *
56
+ * It is not an edit session, it names no user, and it grants nothing but
57
+ * "render this one entry as though it were live". The render it authorizes
58
+ * writes nothing: a preview never publishes a due schedule and never records
59
+ * a refusal, because nothing but a PUBLIC render may move a content entry's
60
+ * state (see `flipDueEntry`).
61
+ */ /**
62
+ * Two hours.
63
+ *
64
+ * The request this was built for is "send me their preview links" — the link
65
+ * is meant to leave the console, ride a chat message and be opened on whatever
66
+ * device is to hand, so a window measured in minutes fails at the only job it
67
+ * has. Two hours covers a sitting: read three posts, come back after a call.
68
+ *
69
+ * It stays hours and not days because a URL is a bearer capability with no
70
+ * revocation, and because URLs are LOGGED — by the platform, by proxies, by
71
+ * whatever chat client carried it. The same property keeps the media
72
+ * signature at fifteen minutes, the gated-video session at four hours and the
73
+ * edit-hint bounce at sixty seconds. Past one sitting the right answer is to
74
+ * ask the console for another link, which re-checks that the person may still
75
+ * edit that site before it signs anything.
76
+ */ export const COLLECTION_PREVIEW_TTL_MS = 2 * 60 * 60 * 1000;
77
+ /**
78
+ * The longest lifetime any preview token may claim, enforced by the VERIFIER
79
+ * as well as the minter — the guard `MEDIA_SIGNATURE_MAX_TTL_MS` describes.
80
+ * `exp` is signed, so this bounds OUR mistakes rather than an attacker's: a
81
+ * minter that one day passes a day where it meant two hours hands out links
82
+ * nothing downstream would otherwise question.
83
+ */ export const COLLECTION_PREVIEW_MAX_TTL_MS = COLLECTION_PREVIEW_TTL_MS;
84
+ /**
85
+ * Allowance for the minting and verifying machines disagreeing about the
86
+ * time. The console signs and the tenant verifies, on different instances —
87
+ * the same minute `media-signing.ts` allows itself.
88
+ */ const CLOCK_SKEW_MS = 60 * 1000;
89
+ /** Version tag, mirroring the edit-hint token's debuggability rationale. */ const TOKEN_PREFIX = 'aglyn-entry-preview-v1';
90
+ /** The longest token this verifier will even look at. */ const TOKEN_MAX_LENGTH = 4096;
91
+ function signPayload(payloadB64) {
92
+ return createHmac('sha256', tokenSigningSecret()).update(`collection-preview:${payloadB64}`).digest('base64url');
93
+ }
94
+ /**
95
+ * Mints a preview grant for a scope the caller has ALREADY authorized. The
96
+ * mint itself checks nothing but its own arguments — the console route around
97
+ * it proves the caller may edit this site's content before it calls.
98
+ */ export function mintCollectionPreviewToken(scope, nowMs = Date.now()) {
99
+ const { hostId, collectionSlug, entrySlug } = scope;
100
+ if (!hostId || !collectionSlug || !entrySlug) {
101
+ throw new Error('hostId, collectionSlug and entrySlug are required');
102
+ }
103
+ const exp = nowMs + COLLECTION_PREVIEW_TTL_MS;
104
+ const claims = {
105
+ hostId,
106
+ collectionSlug,
107
+ entrySlug,
108
+ exp
109
+ };
110
+ const payload = Buffer.from(JSON.stringify(claims), 'utf8').toString('base64url');
111
+ return {
112
+ token: `${TOKEN_PREFIX}.${payload}.${signPayload(payload)}`,
113
+ expiresAtMs: exp
114
+ };
115
+ }
116
+ /**
117
+ * Whether this presented token authorizes THIS scope right now.
118
+ *
119
+ * Answers a boolean rather than the claims, deliberately: the only thing a
120
+ * caller may do with a verified preview token is reveal the entry the ROUTE
121
+ * already named, so handing back a decoded `entrySlug` would invite a caller
122
+ * to trust the token's spelling over the route's and re-open the substitution
123
+ * this scoping exists to close.
124
+ *
125
+ * Refuses — with no distinction the caller can act on — a token that is
126
+ * malformed, the wrong version, tampered with, expired, claiming a lifetime
127
+ * past {@link COLLECTION_PREVIEW_MAX_TTL_MS}, minted for another host, or
128
+ * minted for another collection or entry. A deploy with no signing secret
129
+ * refuses too, like every signature in this family.
130
+ */ export function verifyCollectionPreviewToken(token, scope, nowMs = Date.now()) {
131
+ if (typeof token !== 'string' || token.length === 0 || token.length > TOKEN_MAX_LENGTH) {
132
+ return false;
133
+ }
134
+ const parts = token.split('.');
135
+ if (parts.length !== 3 || parts[0] !== TOKEN_PREFIX) return false;
136
+ const [, payload, sig] = parts;
137
+ let expected;
138
+ try {
139
+ expected = signPayload(payload);
140
+ } catch (unused) {
141
+ // Secret missing — refuse rather than trusting anything.
142
+ return false;
143
+ }
144
+ // `safeEqual`, not a bare `===`: constant-time on equal-length input and
145
+ // length-checked first, because `timingSafeEqual` throws on the length an
146
+ // attacker controls.
147
+ if (!safeEqual(sig, expected)) return false;
148
+ let claims;
149
+ try {
150
+ claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
151
+ } catch (unused) {
152
+ return false;
153
+ }
154
+ const exp = Number(claims == null ? void 0 : claims.exp);
155
+ if (!Number.isFinite(exp)) return false;
156
+ if (exp <= nowMs) return false;
157
+ // The platform-mistake bound. Skew is allowed on the FLOOR of the window
158
+ // (the two clocks disagreeing) and not added to the ceiling, so a token that
159
+ // claims more than the maximum is refused however the clocks read.
160
+ if (exp - nowMs > COLLECTION_PREVIEW_MAX_TTL_MS + CLOCK_SKEW_MS) return false;
161
+ // Every part of the scope, and all three compared against what the REQUEST
162
+ // resolved rather than what the token says about itself.
163
+ return claims.hostId === scope.hostId && claims.collectionSlug === scope.collectionSlug && claims.entrySlug === scope.entrySlug;
164
+ }
165
+
166
+ //# sourceMappingURL=collection-preview-token.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/collection-preview-token.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 { createHmac } from 'crypto'\nimport { tokenSigningSecret } from './media-signing'\nimport { safeEqual } from './safe-equal'\n\n/**\n * A capability to see ONE not-yet-live collection entry on the LIVE site\n * (AGL-3205).\n *\n * The console is `app.aglyn.com`; a tenant is `aglyn.com`, `{sub}.aglyn.app`\n * and every customer's own domain. A console session does not carry to any of\n * them — different registrable domains, storage-partitioned — so \"I am logged\n * in, let me see the draft\" cannot be answered by a session at all. What DOES\n * carry is a URL, so the capability travels as a signed query parameter and is\n * verified by the tenant server before it loads anything withheld.\n *\n * ## What the signature is over, and why each part is in it\n *\n * `{hostId}:{collectionSlug}:{entrySlug}:{exp}`, under the\n * `collection-preview:` context prefix.\n *\n * - `hostId` — a token minted for one site must not work on another. The\n * tenant compares it against the host it RESOLVED from the request, never\n * against anything the URL says, so presenting `aglyn.com`'s token to\n * another customer's domain fails on the host it actually reached.\n * - `collectionSlug` + `entrySlug` — the scope is one post, not \"previews are\n * on for this site\". The verifier is handed the slugs parsed from the route\n * and refuses a mismatch, so a token for `/blog/post-a` cannot be edited\n * into `/blog/post-b` or into `/news/post-a` and reveal either.\n * - `exp` — inside the payload, so nobody without the secret can stretch it.\n *\n * ## The secret\n *\n * `TOKEN_SIGNING_SECRET`, the shared fail-closed secret the media, download,\n * stream, edit-hint and form-binding signatures already use — for the reason\n * `media-signing.ts` gives about not adding a second thing to keep in step\n * across two apps. The `collection-preview:` prefix domain-separates it from\n * every one of those, so none of them can be replayed as a preview grant and\n * a preview grant cannot be replayed as any of them.\n *\n * ## What it is NOT\n *\n * It is not an edit session, it names no user, and it grants nothing but\n * \"render this one entry as though it were live\". The render it authorizes\n * writes nothing: a preview never publishes a due schedule and never records\n * a refusal, because nothing but a PUBLIC render may move a content entry's\n * state (see `flipDueEntry`).\n */\n\n/**\n * Two hours.\n *\n * The request this was built for is \"send me their preview links\" — the link\n * is meant to leave the console, ride a chat message and be opened on whatever\n * device is to hand, so a window measured in minutes fails at the only job it\n * has. Two hours covers a sitting: read three posts, come back after a call.\n *\n * It stays hours and not days because a URL is a bearer capability with no\n * revocation, and because URLs are LOGGED — by the platform, by proxies, by\n * whatever chat client carried it. The same property keeps the media\n * signature at fifteen minutes, the gated-video session at four hours and the\n * edit-hint bounce at sixty seconds. Past one sitting the right answer is to\n * ask the console for another link, which re-checks that the person may still\n * edit that site before it signs anything.\n */\nexport const COLLECTION_PREVIEW_TTL_MS = 2 * 60 * 60 * 1000\n\n/**\n * The longest lifetime any preview token may claim, enforced by the VERIFIER\n * as well as the minter — the guard `MEDIA_SIGNATURE_MAX_TTL_MS` describes.\n * `exp` is signed, so this bounds OUR mistakes rather than an attacker's: a\n * minter that one day passes a day where it meant two hours hands out links\n * nothing downstream would otherwise question.\n */\nexport const COLLECTION_PREVIEW_MAX_TTL_MS = COLLECTION_PREVIEW_TTL_MS\n\n/**\n * Allowance for the minting and verifying machines disagreeing about the\n * time. The console signs and the tenant verifies, on different instances —\n * the same minute `media-signing.ts` allows itself.\n */\nconst CLOCK_SKEW_MS = 60 * 1000\n\n/** Version tag, mirroring the edit-hint token's debuggability rationale. */\nconst TOKEN_PREFIX = 'aglyn-entry-preview-v1'\n\n/** The longest token this verifier will even look at. */\nconst TOKEN_MAX_LENGTH = 4096\n\nexport interface CollectionPreviewScope {\n /** The host the entry belongs to — the resolved one, never a claimed one. */\n hostId: string\n /** The collection's public slug, as the route spells it. */\n collectionSlug: string\n /** The entry's public slug, as the route spells it. */\n entrySlug: string\n}\n\nexport interface CollectionPreviewClaims extends CollectionPreviewScope {\n /** Expiry, epoch ms. */\n exp: number\n}\n\nexport interface MintedCollectionPreviewToken {\n token: string\n expiresAtMs: number\n}\n\nfunction signPayload(payloadB64: string): string {\n return createHmac('sha256', tokenSigningSecret())\n .update(`collection-preview:${payloadB64}`)\n .digest('base64url')\n}\n\n/**\n * Mints a preview grant for a scope the caller has ALREADY authorized. The\n * mint itself checks nothing but its own arguments — the console route around\n * it proves the caller may edit this site's content before it calls.\n */\nexport function mintCollectionPreviewToken(\n scope: CollectionPreviewScope,\n nowMs: number = Date.now(),\n): MintedCollectionPreviewToken {\n const { hostId, collectionSlug, entrySlug } = scope\n if (!hostId || !collectionSlug || !entrySlug) {\n throw new Error('hostId, collectionSlug and entrySlug are required')\n }\n const exp = nowMs + COLLECTION_PREVIEW_TTL_MS\n const claims: CollectionPreviewClaims = { hostId, collectionSlug, entrySlug, exp }\n const payload = Buffer.from(JSON.stringify(claims), 'utf8').toString(\n 'base64url',\n )\n return { token: `${TOKEN_PREFIX}.${payload}.${signPayload(payload)}`, expiresAtMs: exp }\n}\n\n/**\n * Whether this presented token authorizes THIS scope right now.\n *\n * Answers a boolean rather than the claims, deliberately: the only thing a\n * caller may do with a verified preview token is reveal the entry the ROUTE\n * already named, so handing back a decoded `entrySlug` would invite a caller\n * to trust the token's spelling over the route's and re-open the substitution\n * this scoping exists to close.\n *\n * Refuses — with no distinction the caller can act on — a token that is\n * malformed, the wrong version, tampered with, expired, claiming a lifetime\n * past {@link COLLECTION_PREVIEW_MAX_TTL_MS}, minted for another host, or\n * minted for another collection or entry. A deploy with no signing secret\n * refuses too, like every signature in this family.\n */\nexport function verifyCollectionPreviewToken(\n token: unknown,\n scope: CollectionPreviewScope,\n nowMs: number = Date.now(),\n): boolean {\n if (\n typeof token !== 'string' ||\n token.length === 0 ||\n token.length > TOKEN_MAX_LENGTH\n ) {\n return false\n }\n const parts = token.split('.')\n if (parts.length !== 3 || parts[0] !== TOKEN_PREFIX) return false\n const [, payload, sig] = parts\n let expected: string\n try {\n expected = signPayload(payload)\n } catch {\n // Secret missing — refuse rather than trusting anything.\n return false\n }\n // `safeEqual`, not a bare `===`: constant-time on equal-length input and\n // length-checked first, because `timingSafeEqual` throws on the length an\n // attacker controls.\n if (!safeEqual(sig, expected)) return false\n\n let claims: CollectionPreviewClaims\n try {\n claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'))\n } catch {\n return false\n }\n const exp = Number(claims?.exp)\n if (!Number.isFinite(exp)) return false\n if (exp <= nowMs) return false\n // The platform-mistake bound. Skew is allowed on the FLOOR of the window\n // (the two clocks disagreeing) and not added to the ceiling, so a token that\n // claims more than the maximum is refused however the clocks read.\n if (exp - nowMs > COLLECTION_PREVIEW_MAX_TTL_MS + CLOCK_SKEW_MS) return false\n\n // Every part of the scope, and all three compared against what the REQUEST\n // resolved rather than what the token says about itself.\n return (\n claims.hostId === scope.hostId &&\n claims.collectionSlug === scope.collectionSlug &&\n claims.entrySlug === scope.entrySlug\n )\n}\n"],"names":["createHmac","tokenSigningSecret","safeEqual","COLLECTION_PREVIEW_TTL_MS","COLLECTION_PREVIEW_MAX_TTL_MS","CLOCK_SKEW_MS","TOKEN_PREFIX","TOKEN_MAX_LENGTH","signPayload","payloadB64","update","digest","mintCollectionPreviewToken","scope","nowMs","Date","now","hostId","collectionSlug","entrySlug","Error","exp","claims","payload","Buffer","from","JSON","stringify","toString","token","expiresAtMs","verifyCollectionPreviewToken","length","parts","split","sig","expected","parse","Number","isFinite"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,QAAQ,SAAQ;AACnC,SAASC,kBAAkB,QAAQ,qBAAiB;AACpD,SAASC,SAAS,QAAQ,kBAAc;AAExC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0CC,GAED;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,4BAA4B,IAAI,KAAK,KAAK,KAAI;AAE3D;;;;;;CAMC,GACD,OAAO,MAAMC,gCAAgCD,0BAAyB;AAEtE;;;;CAIC,GACD,MAAME,gBAAgB,KAAK;AAE3B,0EAA0E,GAC1E,MAAMC,eAAe;AAErB,uDAAuD,GACvD,MAAMC,mBAAmB;AAqBzB,SAASC,YAAYC,UAAkB;IACrC,OAAOT,WAAW,UAAUC,sBACzBS,MAAM,CAAC,CAAC,mBAAmB,EAAED,YAAY,EACzCE,MAAM,CAAC;AACZ;AAEA;;;;CAIC,GACD,OAAO,SAASC,2BACdC,KAA6B,EAC7BC,QAAgBC,KAAKC,GAAG,EAAE;IAE1B,MAAM,EAAEC,MAAM,EAAEC,cAAc,EAAEC,SAAS,EAAE,GAAGN;IAC9C,IAAI,CAACI,UAAU,CAACC,kBAAkB,CAACC,WAAW;QAC5C,MAAM,IAAIC,MAAM;IAClB;IACA,MAAMC,MAAMP,QAAQX;IACpB,MAAMmB,SAAkC;QAAEL;QAAQC;QAAgBC;QAAWE;IAAI;IACjF,MAAME,UAAUC,OAAOC,IAAI,CAACC,KAAKC,SAAS,CAACL,SAAS,QAAQM,QAAQ,CAClE;IAEF,OAAO;QAAEC,OAAO,GAAGvB,aAAa,CAAC,EAAEiB,QAAQ,CAAC,EAAEf,YAAYe,UAAU;QAAEO,aAAaT;IAAI;AACzF;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASU,6BACdF,KAAc,EACdhB,KAA6B,EAC7BC,QAAgBC,KAAKC,GAAG,EAAE;IAE1B,IACE,OAAOa,UAAU,YACjBA,MAAMG,MAAM,KAAK,KACjBH,MAAMG,MAAM,GAAGzB,kBACf;QACA,OAAO;IACT;IACA,MAAM0B,QAAQJ,MAAMK,KAAK,CAAC;IAC1B,IAAID,MAAMD,MAAM,KAAK,KAAKC,KAAK,CAAC,EAAE,KAAK3B,cAAc,OAAO;IAC5D,MAAM,GAAGiB,SAASY,IAAI,GAAGF;IACzB,IAAIG;IACJ,IAAI;QACFA,WAAW5B,YAAYe;IACzB,EAAE,eAAM;QACN,yDAAyD;QACzD,OAAO;IACT;IACA,yEAAyE;IACzE,0EAA0E;IAC1E,qBAAqB;IACrB,IAAI,CAACrB,UAAUiC,KAAKC,WAAW,OAAO;IAEtC,IAAId;IACJ,IAAI;QACFA,SAASI,KAAKW,KAAK,CAACb,OAAOC,IAAI,CAACF,SAAS,aAAaK,QAAQ,CAAC;IACjE,EAAE,eAAM;QACN,OAAO;IACT;IACA,MAAMP,MAAMiB,OAAOhB,0BAAAA,OAAQD,GAAG;IAC9B,IAAI,CAACiB,OAAOC,QAAQ,CAAClB,MAAM,OAAO;IAClC,IAAIA,OAAOP,OAAO,OAAO;IACzB,yEAAyE;IACzE,6EAA6E;IAC7E,mEAAmE;IACnE,IAAIO,MAAMP,QAAQV,gCAAgCC,eAAe,OAAO;IAExE,2EAA2E;IAC3E,yDAAyD;IACzD,OACEiB,OAAOL,MAAM,KAAKJ,MAAMI,MAAM,IAC9BK,OAAOJ,cAAc,KAAKL,MAAMK,cAAc,IAC9CI,OAAOH,SAAS,KAAKN,MAAMM,SAAS;AAExC"}
@@ -54,3 +54,28 @@ export declare function editAccessMintRefusal(options: {
54
54
  /** From verified ID-token claims; a hint carries none, so it stays false. */
55
55
  staff?: boolean;
56
56
  }): Promise<Response | null>;
57
+ /**
58
+ * Steps 2–4 of the gate above, WITHOUT the edit-bar release flag (AGL-3205).
59
+ *
60
+ * "May this uid edit this host's content?" is a question more than one feature
61
+ * asks. The live-site preview of an unpublished entry asks it — the link it
62
+ * mints reveals a post the public cannot see, so the person asking for one has
63
+ * to be someone who could have published it — and it is emphatically NOT the
64
+ * edit bar: `release_edit_bar` is that surface's kill switch, and a preview
65
+ * link has no business disappearing when somebody turns the bar off.
66
+ *
67
+ * Split rather than copied, for the reason this module exists at all: the
68
+ * whole note above is about not growing a second authorization path that
69
+ * drifts from the first. Callers that ARE the edit bar keep calling
70
+ * {@link editAccessMintRefusal} and keep its flag.
71
+ */
72
+ export declare function hostContentEditRefusal(options: {
73
+ request: Request;
74
+ firestore: Firestore;
75
+ /** The `hosts/{hostId}` snapshot, already confirmed to exist. */
76
+ host: DocumentSnapshot;
77
+ orgId: string;
78
+ uid: string;
79
+ /** From verified ID-token claims; a hint carries none, so it stays false. */
80
+ staff?: boolean;
81
+ }): Promise<Response | null>;
@@ -46,15 +46,32 @@ import { isServerReleaseFlagOnForOrg } from "./release-flags.js";
46
46
  * console, a signed hint at the tenant exchange) — identity is the caller's
47
47
  * business; permission is this function's.
48
48
  */ export async function editAccessMintRefusal(options) {
49
- var _membership_data, _host_get, _ref;
50
- const { request, firestore, host, orgId, uid, staff = false } = options;
51
- if (!await isServerReleaseFlagOnForOrg('release_edit_bar', orgId)) {
49
+ if (!await isServerReleaseFlagOnForOrg('release_edit_bar', options.orgId)) {
52
50
  return Response.json({
53
51
  error: 'Not available'
54
52
  }, {
55
53
  status: 404
56
54
  });
57
55
  }
56
+ return hostContentEditRefusal(options);
57
+ }
58
+ /**
59
+ * Steps 2–4 of the gate above, WITHOUT the edit-bar release flag (AGL-3205).
60
+ *
61
+ * "May this uid edit this host's content?" is a question more than one feature
62
+ * asks. The live-site preview of an unpublished entry asks it — the link it
63
+ * mints reveals a post the public cannot see, so the person asking for one has
64
+ * to be someone who could have published it — and it is emphatically NOT the
65
+ * edit bar: `release_edit_bar` is that surface's kill switch, and a preview
66
+ * link has no business disappearing when somebody turns the bar off.
67
+ *
68
+ * Split rather than copied, for the reason this module exists at all: the
69
+ * whole note above is about not growing a second authorization path that
70
+ * drifts from the first. Callers that ARE the edit bar keep calling
71
+ * {@link editAccessMintRefusal} and keep its flag.
72
+ */ export async function hostContentEditRefusal(options) {
73
+ var _membership_data, _host_get, _ref;
74
+ const { request, firestore, host, orgId, uid, staff = false } = options;
58
75
  const membership = await firestore.collection('orgs').doc(orgId).collection('members').doc(uid).get();
59
76
  if (!membership.exists) {
60
77
  return Response.json({
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/edit-access-authz.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 type AglynOrgMember,\n hostRoleCanWrite,\n hostRoleFor,\n isOrgWideMember,\n} from '@aglyn/aglyn/server'\nimport type { DocumentSnapshot, Firestore } from 'firebase-admin/firestore'\nimport { lockdownRefusal } from './lockdown'\nimport { getOrgDoc } from './organizations'\nimport { isServerReleaseFlagOnForOrg } from './release-flags'\n\n/**\n * THE authorization gate for minting an edit-access token — extracted\n * verbatim from the console's `/api/edit-access/token` (AGL-1302 follow-on)\n * so the tenant's `/api/edit-access/exchange` (AGL-1842) evaluates the SAME\n * question the same way, rather than growing a second authorization path\n * that would drift from the first.\n *\n * The gate, in order:\n *\n * 1. `release_edit_bar` for the host's org — on for every site since its\n * release (AGL-3041), and the kill switch for the whole surface, for\n * everyone or for one org by staff override;\n * 2. org roster membership — proven against the host the CALLER was already\n * resolved to, never a caller-supplied orgId;\n * 3. the co-editing edit gate the presence broker applies, for THIS host: a\n * host role that may write content (admin, editor or author), from the\n * host's `memberRoles` or the member's own `hostAccess`, or an org role\n * above viewer held org-wide (AGL-3062). A viewer gets a plain 403 rather\n * than a read-only bar;\n * 4. the lockdown verdict (AGL-1506) at `intent: 'write'` — the reasoning\n * for `write` on a stateless mint is recorded at length where this code\n * came from and holds unchanged: the token buys entry to an editor whose\n * saves a read-only lock denies, and it outlives this check by its TTL.\n *\n * Returns the refusal Response to send, or `null` when the uid may edit the\n * host. Callers verify WHO the uid is first (a Firebase ID token at the\n * console, a signed hint at the tenant exchange) — identity is the caller's\n * business; permission is this function's.\n */\nexport async function editAccessMintRefusal(options: {\n request: Request\n firestore: Firestore\n /** The `hosts/{hostId}` snapshot, already confirmed to exist. */\n host: DocumentSnapshot\n orgId: string\n uid: string\n /** From verified ID-token claims; a hint carries none, so it stays false. */\n staff?: boolean\n}): Promise<Response | null> {\n const { request, firestore, host, orgId, uid, staff = false } = options\n\n if (!(await isServerReleaseFlagOnForOrg('release_edit_bar', orgId))) {\n return Response.json({ error: 'Not available' }, { status: 404 })\n }\n\n const membership = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('members')\n .doc(uid)\n .get()\n if (!membership.exists) {\n return Response.json({ error: 'Not a member of this site' }, { status: 403 })\n }\n\n // A roster row proves membership of the ORG, not reach to this host. The\n // roster holds site collaborators too, and one can carry the org role\n // `editor` with `allHosts: false` and a `hostAccess` map naming only their\n // own sites — so the org-role arm counts only when the member is org-wide,\n // exactly as the presence broker asks it (AGL-1881). Otherwise that\n // collaborator would be minted a token for every site in the org, and the\n // bar would show them another site's screens and today's traffic\n // (AGL-3062).\n //\n // `hostRoleFor` reads `hostAccess`/`allHosts` for this host, and\n // `isOrgWideMember` sits beside it for the one shape the two read\n // differently: a pre-`allHosts` row with no `hostAccess` is org-wide, and\n // `hostRoleFor` alone would lock that member out of their own sites.\n const member = (membership.data() ?? {}) as Partial<AglynOrgMember>\n const orgRole = member.role\n const hostRole = ((host.get('memberRoles') ?? {}) as Record<string, string>)[\n uid\n ]\n // `author` (AGL-2334) — the edit-bar entry token. Same reasoning as the\n // presence broker: this buys entry to the editor, and an author's whole\n // purpose is to be in it. What they cannot do once inside is enforced by\n // the rules on the publish fields.\n const canEdit =\n hostRoleCanWrite(hostRole) ||\n hostRoleCanWrite(hostRoleFor(member, host.id)) ||\n (isOrgWideMember(member) &&\n (orgRole === 'owner' || orgRole === 'admin' || orgRole === 'editor'))\n if (!canEdit) {\n return Response.json({ error: 'No edit access' }, { status: 403 })\n }\n\n // Host doc already in hand; the org scope rides the member doc's\n // `orgSuspended` projection (also already read) — the org doc is fetched\n // only when the projection trips, so the happy path adds no org read.\n return lockdownRefusal({\n request,\n intent: 'write',\n staff,\n uid,\n org:\n membership.get('orgSuspended') === true\n ? ((await getOrgDoc(orgId)) ?? {})\n : undefined,\n host: host.data(),\n })\n}\n"],"names":["hostRoleCanWrite","hostRoleFor","isOrgWideMember","lockdownRefusal","getOrgDoc","isServerReleaseFlagOnForOrg","editAccessMintRefusal","options","membership","host","request","firestore","orgId","uid","staff","Response","json","error","status","collection","doc","get","exists","member","data","orgRole","role","hostRole","canEdit","id","intent","org","undefined"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAEEA,gBAAgB,EAChBC,WAAW,EACXC,eAAe,QACV,sBAAqB;AAE5B,SAASC,eAAe,QAAQ,gBAAY;AAC5C,SAASC,SAAS,QAAQ,qBAAiB;AAC3C,SAASC,2BAA2B,QAAQ,qBAAiB;AAE7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,eAAeC,sBAAsBC,OAS3C;QA8BiBC,kBAEGC,WA0BT;IAzDV,MAAM,EAAEC,OAAO,EAAEC,SAAS,EAAEF,IAAI,EAAEG,KAAK,EAAEC,GAAG,EAAEC,QAAQ,KAAK,EAAE,GAAGP;IAEhE,IAAI,CAAE,MAAMF,4BAA4B,oBAAoBO,QAAS;QACnE,OAAOG,SAASC,IAAI,CAAC;YAAEC,OAAO;QAAgB,GAAG;YAAEC,QAAQ;QAAI;IACjE;IAEA,MAAMV,aAAa,MAAMG,UACtBQ,UAAU,CAAC,QACXC,GAAG,CAACR,OACJO,UAAU,CAAC,WACXC,GAAG,CAACP,KACJQ,GAAG;IACN,IAAI,CAACb,WAAWc,MAAM,EAAE;QACtB,OAAOP,SAASC,IAAI,CAAC;YAAEC,OAAO;QAA4B,GAAG;YAAEC,QAAQ;QAAI;IAC7E;IAEA,yEAAyE;IACzE,sEAAsE;IACtE,2EAA2E;IAC3E,2EAA2E;IAC3E,oEAAoE;IACpE,0EAA0E;IAC1E,iEAAiE;IACjE,cAAc;IACd,EAAE;IACF,iEAAiE;IACjE,kEAAkE;IAClE,0EAA0E;IAC1E,qEAAqE;IACrE,MAAMK,UAAUf,mBAAAA,WAAWgB,IAAI,cAAfhB,mBAAqB,CAAC;IACtC,MAAMiB,UAAUF,OAAOG,IAAI;IAC3B,MAAMC,WAAW,EAAElB,YAAAA,KAAKY,GAAG,CAAC,0BAATZ,YAA2B,CAAC,EAA6B,CAC1EI,IACD;IACD,wEAAwE;IACxE,wEAAwE;IACxE,yEAAyE;IACzE,mCAAmC;IACnC,MAAMe,UACJ5B,iBAAiB2B,aACjB3B,iBAAiBC,YAAYsB,QAAQd,KAAKoB,EAAE,MAC3C3B,gBAAgBqB,WACdE,CAAAA,YAAY,WAAWA,YAAY,WAAWA,YAAY,QAAO;IACtE,IAAI,CAACG,SAAS;QACZ,OAAOb,SAASC,IAAI,CAAC;YAAEC,OAAO;QAAiB,GAAG;YAAEC,QAAQ;QAAI;IAClE;IAEA,iEAAiE;IACjE,yEAAyE;IACzE,sEAAsE;IACtE,OAAOf,gBAAgB;QACrBO;QACAoB,QAAQ;QACRhB;QACAD;QACAkB,KACEvB,WAAWa,GAAG,CAAC,oBAAoB,QAC7B,OAAA,MAAMjB,UAAUQ,kBAAhB,OAA2B,CAAC,IAC9BoB;QACNvB,MAAMA,KAAKe,IAAI;IACjB;AACF"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/edit-access-authz.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 type AglynOrgMember,\n hostRoleCanWrite,\n hostRoleFor,\n isOrgWideMember,\n} from '@aglyn/aglyn/server'\nimport type { DocumentSnapshot, Firestore } from 'firebase-admin/firestore'\nimport { lockdownRefusal } from './lockdown'\nimport { getOrgDoc } from './organizations'\nimport { isServerReleaseFlagOnForOrg } from './release-flags'\n\n/**\n * THE authorization gate for minting an edit-access token — extracted\n * verbatim from the console's `/api/edit-access/token` (AGL-1302 follow-on)\n * so the tenant's `/api/edit-access/exchange` (AGL-1842) evaluates the SAME\n * question the same way, rather than growing a second authorization path\n * that would drift from the first.\n *\n * The gate, in order:\n *\n * 1. `release_edit_bar` for the host's org — on for every site since its\n * release (AGL-3041), and the kill switch for the whole surface, for\n * everyone or for one org by staff override;\n * 2. org roster membership — proven against the host the CALLER was already\n * resolved to, never a caller-supplied orgId;\n * 3. the co-editing edit gate the presence broker applies, for THIS host: a\n * host role that may write content (admin, editor or author), from the\n * host's `memberRoles` or the member's own `hostAccess`, or an org role\n * above viewer held org-wide (AGL-3062). A viewer gets a plain 403 rather\n * than a read-only bar;\n * 4. the lockdown verdict (AGL-1506) at `intent: 'write'` — the reasoning\n * for `write` on a stateless mint is recorded at length where this code\n * came from and holds unchanged: the token buys entry to an editor whose\n * saves a read-only lock denies, and it outlives this check by its TTL.\n *\n * Returns the refusal Response to send, or `null` when the uid may edit the\n * host. Callers verify WHO the uid is first (a Firebase ID token at the\n * console, a signed hint at the tenant exchange) — identity is the caller's\n * business; permission is this function's.\n */\nexport async function editAccessMintRefusal(options: {\n request: Request\n firestore: Firestore\n /** The `hosts/{hostId}` snapshot, already confirmed to exist. */\n host: DocumentSnapshot\n orgId: string\n uid: string\n /** From verified ID-token claims; a hint carries none, so it stays false. */\n staff?: boolean\n}): Promise<Response | null> {\n if (!(await isServerReleaseFlagOnForOrg('release_edit_bar', options.orgId))) {\n return Response.json({ error: 'Not available' }, { status: 404 })\n }\n return hostContentEditRefusal(options)\n}\n\n/**\n * Steps 2–4 of the gate above, WITHOUT the edit-bar release flag (AGL-3205).\n *\n * \"May this uid edit this host's content?\" is a question more than one feature\n * asks. The live-site preview of an unpublished entry asks it — the link it\n * mints reveals a post the public cannot see, so the person asking for one has\n * to be someone who could have published it — and it is emphatically NOT the\n * edit bar: `release_edit_bar` is that surface's kill switch, and a preview\n * link has no business disappearing when somebody turns the bar off.\n *\n * Split rather than copied, for the reason this module exists at all: the\n * whole note above is about not growing a second authorization path that\n * drifts from the first. Callers that ARE the edit bar keep calling\n * {@link editAccessMintRefusal} and keep its flag.\n */\nexport async function hostContentEditRefusal(options: {\n request: Request\n firestore: Firestore\n /** The `hosts/{hostId}` snapshot, already confirmed to exist. */\n host: DocumentSnapshot\n orgId: string\n uid: string\n /** From verified ID-token claims; a hint carries none, so it stays false. */\n staff?: boolean\n}): Promise<Response | null> {\n const { request, firestore, host, orgId, uid, staff = false } = options\n\n const membership = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('members')\n .doc(uid)\n .get()\n if (!membership.exists) {\n return Response.json({ error: 'Not a member of this site' }, { status: 403 })\n }\n\n // A roster row proves membership of the ORG, not reach to this host. The\n // roster holds site collaborators too, and one can carry the org role\n // `editor` with `allHosts: false` and a `hostAccess` map naming only their\n // own sites — so the org-role arm counts only when the member is org-wide,\n // exactly as the presence broker asks it (AGL-1881). Otherwise that\n // collaborator would be minted a token for every site in the org, and the\n // bar would show them another site's screens and today's traffic\n // (AGL-3062).\n //\n // `hostRoleFor` reads `hostAccess`/`allHosts` for this host, and\n // `isOrgWideMember` sits beside it for the one shape the two read\n // differently: a pre-`allHosts` row with no `hostAccess` is org-wide, and\n // `hostRoleFor` alone would lock that member out of their own sites.\n const member = (membership.data() ?? {}) as Partial<AglynOrgMember>\n const orgRole = member.role\n const hostRole = ((host.get('memberRoles') ?? {}) as Record<string, string>)[\n uid\n ]\n // `author` (AGL-2334) — the edit-bar entry token. Same reasoning as the\n // presence broker: this buys entry to the editor, and an author's whole\n // purpose is to be in it. What they cannot do once inside is enforced by\n // the rules on the publish fields.\n const canEdit =\n hostRoleCanWrite(hostRole) ||\n hostRoleCanWrite(hostRoleFor(member, host.id)) ||\n (isOrgWideMember(member) &&\n (orgRole === 'owner' || orgRole === 'admin' || orgRole === 'editor'))\n if (!canEdit) {\n return Response.json({ error: 'No edit access' }, { status: 403 })\n }\n\n // Host doc already in hand; the org scope rides the member doc's\n // `orgSuspended` projection (also already read) — the org doc is fetched\n // only when the projection trips, so the happy path adds no org read.\n return lockdownRefusal({\n request,\n intent: 'write',\n staff,\n uid,\n org:\n membership.get('orgSuspended') === true\n ? ((await getOrgDoc(orgId)) ?? {})\n : undefined,\n host: host.data(),\n })\n}\n"],"names":["hostRoleCanWrite","hostRoleFor","isOrgWideMember","lockdownRefusal","getOrgDoc","isServerReleaseFlagOnForOrg","editAccessMintRefusal","options","orgId","Response","json","error","status","hostContentEditRefusal","membership","host","request","firestore","uid","staff","collection","doc","get","exists","member","data","orgRole","role","hostRole","canEdit","id","intent","org","undefined"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAEEA,gBAAgB,EAChBC,WAAW,EACXC,eAAe,QACV,sBAAqB;AAE5B,SAASC,eAAe,QAAQ,gBAAY;AAC5C,SAASC,SAAS,QAAQ,qBAAiB;AAC3C,SAASC,2BAA2B,QAAQ,qBAAiB;AAE7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,eAAeC,sBAAsBC,OAS3C;IACC,IAAI,CAAE,MAAMF,4BAA4B,oBAAoBE,QAAQC,KAAK,GAAI;QAC3E,OAAOC,SAASC,IAAI,CAAC;YAAEC,OAAO;QAAgB,GAAG;YAAEC,QAAQ;QAAI;IACjE;IACA,OAAOC,uBAAuBN;AAChC;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,eAAeM,uBAAuBN,OAS5C;QA0BiBO,kBAEGC,WA0BT;IArDV,MAAM,EAAEC,OAAO,EAAEC,SAAS,EAAEF,IAAI,EAAEP,KAAK,EAAEU,GAAG,EAAEC,QAAQ,KAAK,EAAE,GAAGZ;IAEhE,MAAMO,aAAa,MAAMG,UACtBG,UAAU,CAAC,QACXC,GAAG,CAACb,OACJY,UAAU,CAAC,WACXC,GAAG,CAACH,KACJI,GAAG;IACN,IAAI,CAACR,WAAWS,MAAM,EAAE;QACtB,OAAOd,SAASC,IAAI,CAAC;YAAEC,OAAO;QAA4B,GAAG;YAAEC,QAAQ;QAAI;IAC7E;IAEA,yEAAyE;IACzE,sEAAsE;IACtE,2EAA2E;IAC3E,2EAA2E;IAC3E,oEAAoE;IACpE,0EAA0E;IAC1E,iEAAiE;IACjE,cAAc;IACd,EAAE;IACF,iEAAiE;IACjE,kEAAkE;IAClE,0EAA0E;IAC1E,qEAAqE;IACrE,MAAMY,UAAUV,mBAAAA,WAAWW,IAAI,cAAfX,mBAAqB,CAAC;IACtC,MAAMY,UAAUF,OAAOG,IAAI;IAC3B,MAAMC,WAAW,EAAEb,YAAAA,KAAKO,GAAG,CAAC,0BAATP,YAA2B,CAAC,EAA6B,CAC1EG,IACD;IACD,wEAAwE;IACxE,wEAAwE;IACxE,yEAAyE;IACzE,mCAAmC;IACnC,MAAMW,UACJ7B,iBAAiB4B,aACjB5B,iBAAiBC,YAAYuB,QAAQT,KAAKe,EAAE,MAC3C5B,gBAAgBsB,WACdE,CAAAA,YAAY,WAAWA,YAAY,WAAWA,YAAY,QAAO;IACtE,IAAI,CAACG,SAAS;QACZ,OAAOpB,SAASC,IAAI,CAAC;YAAEC,OAAO;QAAiB,GAAG;YAAEC,QAAQ;QAAI;IAClE;IAEA,iEAAiE;IACjE,yEAAyE;IACzE,sEAAsE;IACtE,OAAOT,gBAAgB;QACrBa;QACAe,QAAQ;QACRZ;QACAD;QACAc,KACElB,WAAWQ,GAAG,CAAC,oBAAoB,QAC7B,OAAA,MAAMlB,UAAUI,kBAAhB,OAA2B,CAAC,IAC9ByB;QACNlB,MAAMA,KAAKU,IAAI;IACjB;AACF"}
package/src/tenancy.d.ts CHANGED
@@ -44,6 +44,7 @@ export * from './lib/server/email-flow-gate';
44
44
  export * from './lib/server/email-marketing-gate';
45
45
  export * from './lib/server/sms-keywords';
46
46
  export * from './lib/server/document-id';
47
+ export * from './lib/server/collection-preview-token';
47
48
  export * from './lib/server/edit-access-authz';
48
49
  export * from './lib/server/edit-access-token';
49
50
  export * from './lib/server/edit-hint-token';
package/src/tenancy.js CHANGED
@@ -53,6 +53,7 @@ export * from "./lib/server/email-flow-gate.js";
53
53
  export * from "./lib/server/email-marketing-gate.js";
54
54
  export * from "./lib/server/sms-keywords.js";
55
55
  export * from "./lib/server/document-id.js";
56
+ export * from "./lib/server/collection-preview-token.js";
56
57
  export * from "./lib/server/edit-access-authz.js";
57
58
  export * from "./lib/server/edit-access-token.js";
58
59
  export * from "./lib/server/edit-hint-token.js";
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/tenant/data/admin/src/tenancy.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2022 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\nexport * from './lib/server/analytics-retention'\nexport * from './lib/server/api-http'\nexport * from './lib/server/api-keys'\nexport * from './lib/server/account-emails'\nexport * from './lib/server/auth-pools'\nexport * from './lib/server/client-error-report'\nexport * from './lib/server/connect-account-status'\nexport * from './lib/server/connect-payout-failure'\nexport * from './lib/server/stripe-account-mode'\nexport * from './lib/server/auth-handoff'\nexport * from './lib/server/console-domains'\nexport * from './lib/server/dns-probe'\nexport * from './lib/server/sending-domains'\nexport * from './lib/server/host-sending-domain'\nexport * from './lib/server/sending-domain-debt'\nexport * from './lib/server/sending-domain-recheck'\n// The hosting seam itself, so a door can ask whether this deployment is able\n// to register a name at all before it promises the caller one.\n// `workspace-domains` re-exports the pieces its own callers share with it; the\n// provider behind it is not among them.\nexport { domainProvider } from './lib/server/domain-provider'\nexport type {\n DomainProvider,\n DomainProviderId,\n DomainScope,\n} from './lib/server/domain-provider'\nexport * from './lib/server/platform-domain-names'\nexport * from './lib/server/consume-once'\nexport * from './lib/server/csp-aggregate'\nexport * from './lib/server/data-storage-gate'\nexport * from './lib/server/contact-suppression'\nexport * from './lib/server/email-suppression'\n// The double opt-in quarantine, after the module whose keying it shares and\n// whose `filterTopicSendable` is the reader that makes it a quarantine rather\n// than a stored fact.\nexport * from './lib/server/email-topic-confirmation'\nexport * from './lib/server/email-unsubscribe-link'\nexport * from './lib/server/email-flow-gate'\n// Side-effecting on purpose, for the reason `email-send-rate` below is:\n// evaluating this module INSTALLS the marketing gate on `sendEmail`, so the\n// unsubscribe header, the suppression check and the frequency ceiling reach\n// every bulk sender without a call at each server entrypoint.\nexport * from './lib/server/email-marketing-gate'\nexport * from './lib/server/sms-keywords'\nexport * from './lib/server/document-id'\nexport * from './lib/server/edit-access-authz'\nexport * from './lib/server/edit-access-token'\nexport * from './lib/server/edit-hint-token'\nexport * from './lib/server/email-metering'\n// Side-effecting on purpose (AGL-2409): evaluating this module INSTALLS the\n// platform send-rate governor on `sendEmail`. Every server surface already\n// imports this barrel, so nothing has to remember to call an installer.\nexport * from './lib/server/campaign-conversion-attribution'\nexport * from './lib/server/email-send-rate'\nexport * from './lib/server/email-sender-reputation'\nexport * from './lib/server/erase'\nexport * from './lib/server/erase-person'\nexport * from './lib/server/dynamic-list-materialize'\nexport * from './lib/server/firebase-admin'\nexport * from './lib/server/free-workspace-cap'\nexport * from './lib/server/ga4-measurement-protocol'\nexport * from './lib/server/host-memberships'\nexport * from './lib/server/host-visitor-records'\nexport * from './lib/server/list-members'\nexport * from './lib/server/lockdown'\nexport * from './lib/server/token-revocation'\nexport * from './lib/server/tenant-write-lockdown'\nexport * from './lib/server/host-email-tokens'\nexport * from './lib/server/notifications'\nexport * from './lib/server/org-billing'\nexport * from './lib/server/organizations'\nexport * from './lib/server/duplicate-activity'\nexport * from './lib/server/duplicate-resource'\nexport * from './lib/server/workspace-domains'\nexport * from './lib/server/membership-recover-throttle'\nexport * from './lib/server/password-reset-throttle'\nexport * from './lib/server/rate-limit-store'\nexport * from './lib/server/verify-email-cooldown'\nexport * from './lib/server/vercel-drain-signature'\nexport * from './lib/server/vercel-log-drain'\nexport * from './lib/server/visitor-write-rate-limit'\nexport * from './lib/server/console-api-rate-limit'\nexport * from './lib/server/realm-plugins'\nexport * from './lib/server/release-flags'\nexport * from './lib/server/resolve-people'\nexport * from './lib/server/member-photo'\nexport * from './lib/server/personal-data-export'\nexport * from './lib/server/media-delivery'\nexport * from './lib/server/media-download-tokens'\nexport * from './lib/server/media-quarantine'\nexport * from './lib/server/media-signing'\nexport * from './lib/server/media-storage-path'\nexport * from './lib/server/safe-equal'\nexport * from './lib/server/media-strong-digest'\nexport * from './lib/server/media-tombstone'\nexport * from './lib/server/media-variants'\nexport * from './lib/server/serve-media-cdn'\nexport * from './lib/server/serve-plugin-fetch'\nexport * from './lib/server/sso-break-glass-owners'\nexport * from './lib/server/sso-domain-policy'\nexport * from './lib/server/sso-enforcement'\nexport * from './lib/server/sso-provisioning'\nexport * from './lib/server/update-existing'\nexport * from './lib/server/contact-company-link'\nexport * from './lib/server/contact-email-index'\nexport * from './lib/server/contact-merge'\nexport * from './lib/server/upsert-contact'\n// The stage floor a won deal applies to its contact (AGL-2641).\nexport * from './lib/server/contact-lifecycle-floor'\nexport * from './lib/server/crm-records'\n// The `nextTaskAtMs` a contact, a company and a deal carry, recomputed from\n// their open tasks by every server-side task writer (AGL-2661).\nexport * from './lib/server/crm-next-activity'\n// Where a one-to-one email's activity row lives and how the delivery\n// webhook advances it (AGL-2615).\nexport * from './lib/server/crm-email-activity'\n// What a confirmed booking files on the CRM record — the meeting and the\n// follow-up (AGL-2660).\nexport * from './lib/server/crm-booking-activity'\n// The org's email capture token and the filing of one received message\n// on the record it was with (AGL-2657).\nexport * from './lib/server/crm-inbound-email'\n// A member's own addresses in a workspace, and the signed link that\n// confirms one (AGL-2975).\nexport * from './lib/server/member-email-aliases'\nexport * from './lib/server/user-profiles'\nexport * from './lib/server/legal-acceptance'\n// The person's decision about the platform's own product email, on their\n// document and on the operator's marketing contact (AGL-3185).\nexport * from './lib/server/platform-marketing-consent'\n"],"names":["domainProvider"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/tenant/data/admin/src/tenancy.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2022 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\nexport * from './lib/server/analytics-retention'\nexport * from './lib/server/api-http'\nexport * from './lib/server/api-keys'\nexport * from './lib/server/account-emails'\nexport * from './lib/server/auth-pools'\nexport * from './lib/server/client-error-report'\nexport * from './lib/server/connect-account-status'\nexport * from './lib/server/connect-payout-failure'\nexport * from './lib/server/stripe-account-mode'\nexport * from './lib/server/auth-handoff'\nexport * from './lib/server/console-domains'\nexport * from './lib/server/dns-probe'\nexport * from './lib/server/sending-domains'\nexport * from './lib/server/host-sending-domain'\nexport * from './lib/server/sending-domain-debt'\nexport * from './lib/server/sending-domain-recheck'\n// The hosting seam itself, so a door can ask whether this deployment is able\n// to register a name at all before it promises the caller one.\n// `workspace-domains` re-exports the pieces its own callers share with it; the\n// provider behind it is not among them.\nexport { domainProvider } from './lib/server/domain-provider'\nexport type {\n DomainProvider,\n DomainProviderId,\n DomainScope,\n} from './lib/server/domain-provider'\nexport * from './lib/server/platform-domain-names'\nexport * from './lib/server/consume-once'\nexport * from './lib/server/csp-aggregate'\nexport * from './lib/server/data-storage-gate'\nexport * from './lib/server/contact-suppression'\nexport * from './lib/server/email-suppression'\n// The double opt-in quarantine, after the module whose keying it shares and\n// whose `filterTopicSendable` is the reader that makes it a quarantine rather\n// than a stored fact.\nexport * from './lib/server/email-topic-confirmation'\nexport * from './lib/server/email-unsubscribe-link'\nexport * from './lib/server/email-flow-gate'\n// Side-effecting on purpose, for the reason `email-send-rate` below is:\n// evaluating this module INSTALLS the marketing gate on `sendEmail`, so the\n// unsubscribe header, the suppression check and the frequency ceiling reach\n// every bulk sender without a call at each server entrypoint.\nexport * from './lib/server/email-marketing-gate'\nexport * from './lib/server/sms-keywords'\nexport * from './lib/server/document-id'\nexport * from './lib/server/collection-preview-token'\nexport * from './lib/server/edit-access-authz'\nexport * from './lib/server/edit-access-token'\nexport * from './lib/server/edit-hint-token'\nexport * from './lib/server/email-metering'\n// Side-effecting on purpose (AGL-2409): evaluating this module INSTALLS the\n// platform send-rate governor on `sendEmail`. Every server surface already\n// imports this barrel, so nothing has to remember to call an installer.\nexport * from './lib/server/campaign-conversion-attribution'\nexport * from './lib/server/email-send-rate'\nexport * from './lib/server/email-sender-reputation'\nexport * from './lib/server/erase'\nexport * from './lib/server/erase-person'\nexport * from './lib/server/dynamic-list-materialize'\nexport * from './lib/server/firebase-admin'\nexport * from './lib/server/free-workspace-cap'\nexport * from './lib/server/ga4-measurement-protocol'\nexport * from './lib/server/host-memberships'\nexport * from './lib/server/host-visitor-records'\nexport * from './lib/server/list-members'\nexport * from './lib/server/lockdown'\nexport * from './lib/server/token-revocation'\nexport * from './lib/server/tenant-write-lockdown'\nexport * from './lib/server/host-email-tokens'\nexport * from './lib/server/notifications'\nexport * from './lib/server/org-billing'\nexport * from './lib/server/organizations'\nexport * from './lib/server/duplicate-activity'\nexport * from './lib/server/duplicate-resource'\nexport * from './lib/server/workspace-domains'\nexport * from './lib/server/membership-recover-throttle'\nexport * from './lib/server/password-reset-throttle'\nexport * from './lib/server/rate-limit-store'\nexport * from './lib/server/verify-email-cooldown'\nexport * from './lib/server/vercel-drain-signature'\nexport * from './lib/server/vercel-log-drain'\nexport * from './lib/server/visitor-write-rate-limit'\nexport * from './lib/server/console-api-rate-limit'\nexport * from './lib/server/realm-plugins'\nexport * from './lib/server/release-flags'\nexport * from './lib/server/resolve-people'\nexport * from './lib/server/member-photo'\nexport * from './lib/server/personal-data-export'\nexport * from './lib/server/media-delivery'\nexport * from './lib/server/media-download-tokens'\nexport * from './lib/server/media-quarantine'\nexport * from './lib/server/media-signing'\nexport * from './lib/server/media-storage-path'\nexport * from './lib/server/safe-equal'\nexport * from './lib/server/media-strong-digest'\nexport * from './lib/server/media-tombstone'\nexport * from './lib/server/media-variants'\nexport * from './lib/server/serve-media-cdn'\nexport * from './lib/server/serve-plugin-fetch'\nexport * from './lib/server/sso-break-glass-owners'\nexport * from './lib/server/sso-domain-policy'\nexport * from './lib/server/sso-enforcement'\nexport * from './lib/server/sso-provisioning'\nexport * from './lib/server/update-existing'\nexport * from './lib/server/contact-company-link'\nexport * from './lib/server/contact-email-index'\nexport * from './lib/server/contact-merge'\nexport * from './lib/server/upsert-contact'\n// The stage floor a won deal applies to its contact (AGL-2641).\nexport * from './lib/server/contact-lifecycle-floor'\nexport * from './lib/server/crm-records'\n// The `nextTaskAtMs` a contact, a company and a deal carry, recomputed from\n// their open tasks by every server-side task writer (AGL-2661).\nexport * from './lib/server/crm-next-activity'\n// Where a one-to-one email's activity row lives and how the delivery\n// webhook advances it (AGL-2615).\nexport * from './lib/server/crm-email-activity'\n// What a confirmed booking files on the CRM record — the meeting and the\n// follow-up (AGL-2660).\nexport * from './lib/server/crm-booking-activity'\n// The org's email capture token and the filing of one received message\n// on the record it was with (AGL-2657).\nexport * from './lib/server/crm-inbound-email'\n// A member's own addresses in a workspace, and the signed link that\n// confirms one (AGL-2975).\nexport * from './lib/server/member-email-aliases'\nexport * from './lib/server/user-profiles'\nexport * from './lib/server/legal-acceptance'\n// The person's decision about the platform's own product email, on their\n// document and on the operator's marketing contact (AGL-3185).\nexport * from './lib/server/platform-marketing-consent'\n"],"names":["domainProvider"],"mappings":"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"}