@aglyn/tenant-data-admin 1.0.0-beta.218 → 1.0.0-beta.219
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 +7 -7
- package/src/lib/server/api-v1-description.d.ts +112 -0
- package/src/lib/server/api-v1-description.js +126 -0
- package/src/lib/server/api-v1-description.js.map +1 -0
- package/src/lib/server/api-v1-kit.d.ts +156 -0
- package/src/lib/server/api-v1-kit.js +191 -0
- package/src/lib/server/api-v1-kit.js.map +1 -0
- package/src/lib/server/api-v1-resources.d.ts +144 -0
- package/src/lib/server/api-v1-resources.js +126 -0
- package/src/lib/server/api-v1-resources.js.map +1 -0
- package/src/lib/server/consent-group-change.d.ts +2 -1
- package/src/lib/server/consent-group-change.js +41 -16
- package/src/lib/server/consent-group-change.js.map +1 -1
- package/src/lib/server/contact-email-index.d.ts +22 -0
- package/src/lib/server/contact-email-index.js +108 -0
- package/src/lib/server/contact-email-index.js.map +1 -1
- package/src/lib/server/crm-email-activity.d.ts +1 -1
- package/src/lib/server/crm-email-activity.js +1 -1
- package/src/lib/server/crm-email-activity.js.map +1 -1
- package/src/lib/server/crm-inbound-email.d.ts +23 -0
- package/src/lib/server/crm-inbound-email.js +1 -16
- package/src/lib/server/crm-inbound-email.js.map +1 -1
- package/src/lib/server/crm-records.d.ts +9 -1
- package/src/lib/server/crm-records.js +19 -2
- package/src/lib/server/crm-records.js.map +1 -1
- package/src/lib/server/cron-auth.d.ts +44 -0
- package/src/lib/server/cron-auth.js +76 -0
- package/src/lib/server/cron-auth.js.map +1 -0
- package/src/lib/server/dataset-live-pages.js +6 -6
- package/src/lib/server/dataset-live-pages.js.map +1 -1
- package/src/lib/server/duplicate-resource.d.ts +26 -0
- package/src/lib/server/duplicate-resource.js +47 -25
- package/src/lib/server/duplicate-resource.js.map +1 -1
- package/src/lib/server/dynamic-list-materialize.js +4 -1
- package/src/lib/server/dynamic-list-materialize.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +11 -0
- package/src/lib/server/email-suppression.js +36 -1
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/email-unsubscribe-link.d.ts +8 -5
- package/src/lib/server/email-unsubscribe-link.js +7 -7
- package/src/lib/server/email-unsubscribe-link.js.map +1 -1
- package/src/lib/server/erase.d.ts +19 -33
- package/src/lib/server/erase.js +37 -162
- package/src/lib/server/erase.js.map +1 -1
- package/src/lib/server/host-email-tokens.js +1 -1
- package/src/lib/server/host-email-tokens.js.map +1 -1
- package/src/lib/server/host-memberships.js +3 -3
- package/src/lib/server/host-memberships.js.map +1 -1
- package/src/lib/server/member-email-aliases.d.ts +1 -1
- package/src/lib/server/member-email-aliases.js +1 -1
- package/src/lib/server/member-email-aliases.js.map +1 -1
- package/src/lib/server/new-account-announcement.d.ts +62 -0
- package/src/lib/server/new-account-announcement.js +77 -0
- package/src/lib/server/new-account-announcement.js.map +1 -0
- package/src/lib/server/notifications.d.ts +8 -2
- package/src/lib/server/notifications.js +11 -3
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
- package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +1 -1
- package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
- package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
- package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
- package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
- package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
- package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
- package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
- package/src/lib/server/payment-provider.d.ts +173 -0
- package/src/lib/server/payment-provider.js +70 -0
- package/src/lib/server/payment-provider.js.map +1 -0
- package/src/lib/server/personal-data-export.js +1 -1
- package/src/lib/server/personal-data-export.js.map +1 -1
- package/src/lib/server/rate-limit-store.js +13 -3
- package/src/lib/server/rate-limit-store.js.map +1 -1
- package/src/lib/server/realm-plugins.js +8 -3
- package/src/lib/server/realm-plugins.js.map +1 -1
- package/src/lib/server/retained-refusals.d.ts +142 -0
- package/src/lib/server/retained-refusals.js +325 -0
- package/src/lib/server/retained-refusals.js.map +1 -0
- package/src/lib/server/test-firestore-queries.d.ts +7 -0
- package/src/lib/server/test-firestore-queries.js +70 -27
- package/src/lib/server/test-firestore-queries.js.map +1 -1
- package/src/lib/server/upsert-contact.d.ts +46 -1
- package/src/lib/server/upsert-contact.js +85 -18
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +4 -5
- package/src/tenancy.js +11 -6
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/connect-account-status.js.map +0 -1
- package/src/lib/server/connect-payout-failure.js.map +0 -1
- package/src/lib/server/contact-lifecycle-floor.d.ts +0 -89
- package/src/lib/server/contact-lifecycle-floor.js +0 -91
- package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
- package/src/lib/server/contact-merge.d.ts +0 -55
- package/src/lib/server/contact-merge.js +0 -288
- package/src/lib/server/contact-merge.js.map +0 -1
- package/src/lib/server/form-dataset-binding-token.d.ts +0 -35
- package/src/lib/server/form-dataset-binding-token.js +0 -107
- package/src/lib/server/form-dataset-binding-token.js.map +0 -1
- package/src/lib/server/stripe-account-mode.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/tenant-data-admin",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.219",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,12 +25,12 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-util-fbserver": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-util-first-touch": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.219",
|
|
29
|
+
"@aglyn/shared-util-email": "1.0.0-beta.219",
|
|
30
|
+
"@aglyn/shared-util-fbserver": "1.0.0-beta.219",
|
|
31
|
+
"@aglyn/shared-util-first-touch": "1.0.0-beta.219",
|
|
32
|
+
"@aglyn/shared-util-http": "1.0.0-beta.219",
|
|
33
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.219",
|
|
34
34
|
"@msgpack/msgpack": "^3.1.3",
|
|
35
35
|
"@swc/helpers": "0.5.23",
|
|
36
36
|
"sharp": "^0.35.5",
|
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
* How a `/v1` resource is described in the customer API's OpenAPI document
|
|
19
|
+
* (AGL-2733), in the shape the console's builder turns into paths, tags and
|
|
20
|
+
* schemas — whoever serves the resource.
|
|
21
|
+
*
|
|
22
|
+
* The platform describes its own resources with these; a plugin that serves
|
|
23
|
+
* one (`api-v1-resources.ts`) hands the builder its description through the
|
|
24
|
+
* registration, so the document, and the MCP tools derived from it, describe
|
|
25
|
+
* every resource the build serves and nothing it does not.
|
|
26
|
+
*
|
|
27
|
+
* Pure data and pure functions: nothing here reads a request or a store.
|
|
28
|
+
*/
|
|
29
|
+
/** A JSON Schema / OpenAPI fragment. */
|
|
30
|
+
export type ApiV1Schema = Record<string, unknown>;
|
|
31
|
+
export interface ApiV1Operation {
|
|
32
|
+
/** Path relative to the server, e.g. `/v1/contacts/{contactId}`. */
|
|
33
|
+
readonly path: string;
|
|
34
|
+
readonly method: 'get' | 'post' | 'patch' | 'delete';
|
|
35
|
+
readonly operationId: string;
|
|
36
|
+
readonly summary: string;
|
|
37
|
+
readonly description?: string;
|
|
38
|
+
/** `true` when the response is the paginated list envelope. */
|
|
39
|
+
readonly list?: boolean;
|
|
40
|
+
/** Schema name for the response body, when it is a single record. */
|
|
41
|
+
readonly returns?: string;
|
|
42
|
+
/** Schema name for the request body. */
|
|
43
|
+
readonly accepts?: string;
|
|
44
|
+
/** Extra query parameters beyond the pagination pair. */
|
|
45
|
+
readonly filters?: readonly ApiV1Schema[];
|
|
46
|
+
/** Path parameters, in order. */
|
|
47
|
+
readonly pathParams?: readonly {
|
|
48
|
+
name: string;
|
|
49
|
+
description: string;
|
|
50
|
+
}[];
|
|
51
|
+
/**
|
|
52
|
+
* `true` for a create: a fresh one answers `201`, and a replay of the same
|
|
53
|
+
* `Idempotency-Key` answers `200` with the record the original create made.
|
|
54
|
+
*/
|
|
55
|
+
readonly creates?: boolean;
|
|
56
|
+
/** Entitlement the call needs, named in the 403. */
|
|
57
|
+
readonly entitlement?: string;
|
|
58
|
+
}
|
|
59
|
+
export interface ApiV1ResourceDescription {
|
|
60
|
+
readonly tag: string;
|
|
61
|
+
readonly description: string;
|
|
62
|
+
readonly schemaName: string;
|
|
63
|
+
readonly fields: Record<string, ApiV1Schema>;
|
|
64
|
+
readonly required: readonly string[];
|
|
65
|
+
readonly ops: readonly ApiV1Operation[];
|
|
66
|
+
/**
|
|
67
|
+
* The members a write body may carry: the handler's own writable set, in the
|
|
68
|
+
* order they are described. A record returns members nothing can write —
|
|
69
|
+
* `siteId`, `sources`, the stamps — and a closed body listing them would tell
|
|
70
|
+
* a generated client to send what the handler refuses.
|
|
71
|
+
*/
|
|
72
|
+
readonly writable?: readonly string[];
|
|
73
|
+
/** Schemas for writable members the record does not return, or returns differently. */
|
|
74
|
+
readonly writeOnly?: Record<string, ApiV1Schema>;
|
|
75
|
+
/** What a create accepts that an update does not, in words. */
|
|
76
|
+
readonly writeNote?: string;
|
|
77
|
+
/** Members a write must carry. Only for a body a single method takes. */
|
|
78
|
+
readonly writeRequired?: readonly string[];
|
|
79
|
+
/**
|
|
80
|
+
* Further named schemas the operations reference — a request body that is
|
|
81
|
+
* not the record's write shape (`ContactMerge`), a response that is not the
|
|
82
|
+
* record (`LeadConversion`). Each replaces a schema of the same name the
|
|
83
|
+
* builder would otherwise derive.
|
|
84
|
+
*/
|
|
85
|
+
readonly components?: Record<string, ApiV1Schema>;
|
|
86
|
+
}
|
|
87
|
+
export declare const isoField: (description: string) => ApiV1Schema;
|
|
88
|
+
export declare const stringField: (description: string) => ApiV1Schema;
|
|
89
|
+
export declare const integerField: (description: string) => ApiV1Schema;
|
|
90
|
+
export declare const booleanField: (description: string) => ApiV1Schema;
|
|
91
|
+
export declare const stringListField: (description: string) => ApiV1Schema;
|
|
92
|
+
/**
|
|
93
|
+
* A field the API may answer with `null`.
|
|
94
|
+
*
|
|
95
|
+
* OpenAPI 3.1 is JSON Schema 2020-12, where nullability is a type UNION and
|
|
96
|
+
* not the 3.0 `nullable: true` keyword — a generator reading `nullable` here
|
|
97
|
+
* would silently produce a non-optional field, which is the whole class of bug
|
|
98
|
+
* the document exists to remove.
|
|
99
|
+
*/
|
|
100
|
+
export declare const nullableField: (base: ApiV1Schema) => ApiV1Schema;
|
|
101
|
+
export declare const openObjectField: (description: string) => ApiV1Schema;
|
|
102
|
+
/** A record's `object` member, which names its kind. */
|
|
103
|
+
export declare const objectKindField: (name: string) => ApiV1Schema;
|
|
104
|
+
export declare const postalAddressField: () => ApiV1Schema;
|
|
105
|
+
/** Every record carries these two. */
|
|
106
|
+
export declare const RECORD_STAMPS: {
|
|
107
|
+
created: ApiV1Schema;
|
|
108
|
+
updated: ApiV1Schema;
|
|
109
|
+
};
|
|
110
|
+
export declare const UPDATED_AFTER_PARAM: ApiV1Schema;
|
|
111
|
+
/** An optional query parameter a list filters by. */
|
|
112
|
+
export declare const queryParam: (name: string, description: string, schema?: ApiV1Schema) => ApiV1Schema;
|
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
* How a `/v1` resource is described in the customer API's OpenAPI document
|
|
18
|
+
* (AGL-2733), in the shape the console's builder turns into paths, tags and
|
|
19
|
+
* schemas — whoever serves the resource.
|
|
20
|
+
*
|
|
21
|
+
* The platform describes its own resources with these; a plugin that serves
|
|
22
|
+
* one (`api-v1-resources.ts`) hands the builder its description through the
|
|
23
|
+
* registration, so the document, and the MCP tools derived from it, describe
|
|
24
|
+
* every resource the build serves and nothing it does not.
|
|
25
|
+
*
|
|
26
|
+
* Pure data and pure functions: nothing here reads a request or a store.
|
|
27
|
+
*/ /** A JSON Schema / OpenAPI fragment. */ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
28
|
+
// ── Field schemas ──────────────────────────────────────────────────────────
|
|
29
|
+
export const isoField = (description)=>({
|
|
30
|
+
type: 'string',
|
|
31
|
+
format: 'date-time',
|
|
32
|
+
description
|
|
33
|
+
});
|
|
34
|
+
export const stringField = (description)=>({
|
|
35
|
+
type: 'string',
|
|
36
|
+
description
|
|
37
|
+
});
|
|
38
|
+
export const integerField = (description)=>({
|
|
39
|
+
type: 'integer',
|
|
40
|
+
description
|
|
41
|
+
});
|
|
42
|
+
export const booleanField = (description)=>({
|
|
43
|
+
type: 'boolean',
|
|
44
|
+
description
|
|
45
|
+
});
|
|
46
|
+
export const stringListField = (description)=>({
|
|
47
|
+
type: 'array',
|
|
48
|
+
items: {
|
|
49
|
+
type: 'string'
|
|
50
|
+
},
|
|
51
|
+
description
|
|
52
|
+
});
|
|
53
|
+
/**
|
|
54
|
+
* A field the API may answer with `null`.
|
|
55
|
+
*
|
|
56
|
+
* OpenAPI 3.1 is JSON Schema 2020-12, where nullability is a type UNION and
|
|
57
|
+
* not the 3.0 `nullable: true` keyword — a generator reading `nullable` here
|
|
58
|
+
* would silently produce a non-optional field, which is the whole class of bug
|
|
59
|
+
* the document exists to remove.
|
|
60
|
+
*/ export const nullableField = (base)=>_extends({}, base, {
|
|
61
|
+
type: [
|
|
62
|
+
base['type'],
|
|
63
|
+
'null'
|
|
64
|
+
]
|
|
65
|
+
});
|
|
66
|
+
export const openObjectField = (description)=>({
|
|
67
|
+
type: 'object',
|
|
68
|
+
description,
|
|
69
|
+
additionalProperties: true
|
|
70
|
+
});
|
|
71
|
+
/** A record's `object` member, which names its kind. */ export const objectKindField = (name)=>({
|
|
72
|
+
type: 'string',
|
|
73
|
+
const: name,
|
|
74
|
+
description: `Always \`${name}\`. Lets a client discriminate a mixed array.`
|
|
75
|
+
});
|
|
76
|
+
export const postalAddressField = ()=>({
|
|
77
|
+
type: 'object',
|
|
78
|
+
description: 'Postal address. Members are optional and free-form.',
|
|
79
|
+
properties: {
|
|
80
|
+
line1: {
|
|
81
|
+
type: 'string'
|
|
82
|
+
},
|
|
83
|
+
line2: {
|
|
84
|
+
type: 'string'
|
|
85
|
+
},
|
|
86
|
+
city: {
|
|
87
|
+
type: 'string'
|
|
88
|
+
},
|
|
89
|
+
region: {
|
|
90
|
+
type: 'string'
|
|
91
|
+
},
|
|
92
|
+
postalCode: {
|
|
93
|
+
type: 'string'
|
|
94
|
+
},
|
|
95
|
+
country: {
|
|
96
|
+
type: 'string'
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
additionalProperties: true
|
|
100
|
+
});
|
|
101
|
+
/** Every record carries these two. */ export const RECORD_STAMPS = {
|
|
102
|
+
created: isoField('When the record was created.'),
|
|
103
|
+
updated: isoField('When the record last changed. CRM lists can be walked by it.')
|
|
104
|
+
};
|
|
105
|
+
// ── Query parameters ───────────────────────────────────────────────────────
|
|
106
|
+
export const UPDATED_AFTER_PARAM = {
|
|
107
|
+
name: 'updatedAfter',
|
|
108
|
+
in: 'query',
|
|
109
|
+
required: false,
|
|
110
|
+
description: 'ISO 8601 instant WITH an offset (`2026-09-01T00:00:00Z`). A bare date ' + 'is a 400 — midnight in whose zone is not a question this API can ' + 'answer. Reorders the list by `updated` ascending, and moves every other ' + 'filter out of the query and onto the page, so pages can come back short.',
|
|
111
|
+
schema: {
|
|
112
|
+
type: 'string',
|
|
113
|
+
format: 'date-time'
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
/** An optional query parameter a list filters by. */ export const queryParam = (name, description, schema = {
|
|
117
|
+
type: 'string'
|
|
118
|
+
})=>({
|
|
119
|
+
name,
|
|
120
|
+
in: 'query',
|
|
121
|
+
required: false,
|
|
122
|
+
description,
|
|
123
|
+
schema
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
//# sourceMappingURL=api-v1-description.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/api-v1-description.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 * How a `/v1` resource is described in the customer API's OpenAPI document\n * (AGL-2733), in the shape the console's builder turns into paths, tags and\n * schemas — whoever serves the resource.\n *\n * The platform describes its own resources with these; a plugin that serves\n * one (`api-v1-resources.ts`) hands the builder its description through the\n * registration, so the document, and the MCP tools derived from it, describe\n * every resource the build serves and nothing it does not.\n *\n * Pure data and pure functions: nothing here reads a request or a store.\n */\n\n/** A JSON Schema / OpenAPI fragment. */\nexport type ApiV1Schema = Record<string, unknown>\n\nexport interface ApiV1Operation {\n /** Path relative to the server, e.g. `/v1/contacts/{contactId}`. */\n readonly path: string\n readonly method: 'get' | 'post' | 'patch' | 'delete'\n readonly operationId: string\n readonly summary: string\n readonly description?: string\n /** `true` when the response is the paginated list envelope. */\n readonly list?: boolean\n /** Schema name for the response body, when it is a single record. */\n readonly returns?: string\n /** Schema name for the request body. */\n readonly accepts?: string\n /** Extra query parameters beyond the pagination pair. */\n readonly filters?: readonly ApiV1Schema[]\n /** Path parameters, in order. */\n readonly pathParams?: readonly { name: string; description: string }[]\n /**\n * `true` for a create: a fresh one answers `201`, and a replay of the same\n * `Idempotency-Key` answers `200` with the record the original create made.\n */\n readonly creates?: boolean\n /** Entitlement the call needs, named in the 403. */\n readonly entitlement?: string\n}\n\nexport interface ApiV1ResourceDescription {\n readonly tag: string\n readonly description: string\n readonly schemaName: string\n readonly fields: Record<string, ApiV1Schema>\n readonly required: readonly string[]\n readonly ops: readonly ApiV1Operation[]\n /**\n * The members a write body may carry: the handler's own writable set, in the\n * order they are described. A record returns members nothing can write —\n * `siteId`, `sources`, the stamps — and a closed body listing them would tell\n * a generated client to send what the handler refuses.\n */\n readonly writable?: readonly string[]\n /** Schemas for writable members the record does not return, or returns differently. */\n readonly writeOnly?: Record<string, ApiV1Schema>\n /** What a create accepts that an update does not, in words. */\n readonly writeNote?: string\n /** Members a write must carry. Only for a body a single method takes. */\n readonly writeRequired?: readonly string[]\n /**\n * Further named schemas the operations reference — a request body that is\n * not the record's write shape (`ContactMerge`), a response that is not the\n * record (`LeadConversion`). Each replaces a schema of the same name the\n * builder would otherwise derive.\n */\n readonly components?: Record<string, ApiV1Schema>\n}\n\n// ── Field schemas ──────────────────────────────────────────────────────────\n\nexport const isoField = (description: string): ApiV1Schema => ({\n type: 'string',\n format: 'date-time',\n description,\n})\n\nexport const stringField = (description: string): ApiV1Schema => ({ type: 'string', description })\nexport const integerField = (description: string): ApiV1Schema => ({ type: 'integer', description })\nexport const booleanField = (description: string): ApiV1Schema => ({ type: 'boolean', description })\nexport const stringListField = (description: string): ApiV1Schema => ({\n type: 'array',\n items: { type: 'string' },\n description,\n})\n\n/**\n * A field the API may answer with `null`.\n *\n * OpenAPI 3.1 is JSON Schema 2020-12, where nullability is a type UNION and\n * not the 3.0 `nullable: true` keyword — a generator reading `nullable` here\n * would silently produce a non-optional field, which is the whole class of bug\n * the document exists to remove.\n */\nexport const nullableField = (base: ApiV1Schema): ApiV1Schema => ({\n ...base,\n type: [base['type'], 'null'],\n})\n\nexport const openObjectField = (description: string): ApiV1Schema => ({\n type: 'object',\n description,\n additionalProperties: true,\n})\n\n/** A record's `object` member, which names its kind. */\nexport const objectKindField = (name: string): ApiV1Schema => ({\n type: 'string',\n const: name,\n description: `Always \\`${name}\\`. Lets a client discriminate a mixed array.`,\n})\n\nexport const postalAddressField = (): ApiV1Schema => ({\n type: 'object',\n description: 'Postal address. Members are optional and free-form.',\n properties: {\n line1: { type: 'string' },\n line2: { type: 'string' },\n city: { type: 'string' },\n region: { type: 'string' },\n postalCode: { type: 'string' },\n country: { type: 'string' },\n },\n additionalProperties: true,\n})\n\n/** Every record carries these two. */\nexport const RECORD_STAMPS = {\n created: isoField('When the record was created.'),\n updated: isoField('When the record last changed. CRM lists can be walked by it.'),\n}\n\n// ── Query parameters ───────────────────────────────────────────────────────\n\nexport const UPDATED_AFTER_PARAM: ApiV1Schema = {\n name: 'updatedAfter',\n in: 'query',\n required: false,\n description:\n 'ISO 8601 instant WITH an offset (`2026-09-01T00:00:00Z`). A bare date ' +\n 'is a 400 — midnight in whose zone is not a question this API can ' +\n 'answer. Reorders the list by `updated` ascending, and moves every other ' +\n 'filter out of the query and onto the page, so pages can come back short.',\n schema: { type: 'string', format: 'date-time' },\n}\n\n/** An optional query parameter a list filters by. */\nexport const queryParam = (\n name: string,\n description: string,\n schema: ApiV1Schema = { type: 'string' },\n): ApiV1Schema => ({\n name,\n in: 'query',\n required: false,\n description,\n schema,\n})\n"],"names":["isoField","description","type","format","stringField","integerField","booleanField","stringListField","items","nullableField","base","openObjectField","additionalProperties","objectKindField","name","const","postalAddressField","properties","line1","line2","city","region","postalCode","country","RECORD_STAMPS","created","updated","UPDATED_AFTER_PARAM","in","required","schema","queryParam"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;CAWC,GAED,sCAAsC;AA0DtC,8EAA8E;AAE9E,OAAO,MAAMA,WAAW,CAACC,cAAsC,CAAA;QAC7DC,MAAM;QACNC,QAAQ;QACRF;IACF,CAAA,EAAE;AAEF,OAAO,MAAMG,cAAc,CAACH,cAAsC,CAAA;QAAEC,MAAM;QAAUD;IAAY,CAAA,EAAE;AAClG,OAAO,MAAMI,eAAe,CAACJ,cAAsC,CAAA;QAAEC,MAAM;QAAWD;IAAY,CAAA,EAAE;AACpG,OAAO,MAAMK,eAAe,CAACL,cAAsC,CAAA;QAAEC,MAAM;QAAWD;IAAY,CAAA,EAAE;AACpG,OAAO,MAAMM,kBAAkB,CAACN,cAAsC,CAAA;QACpEC,MAAM;QACNM,OAAO;YAAEN,MAAM;QAAS;QACxBD;IACF,CAAA,EAAE;AAEF;;;;;;;CAOC,GACD,OAAO,MAAMQ,gBAAgB,CAACC,OAAoC,aAC7DA;QACHR,MAAM;YAACQ,IAAI,CAAC,OAAO;YAAE;SAAO;OAC5B;AAEF,OAAO,MAAMC,kBAAkB,CAACV,cAAsC,CAAA;QACpEC,MAAM;QACND;QACAW,sBAAsB;IACxB,CAAA,EAAE;AAEF,sDAAsD,GACtD,OAAO,MAAMC,kBAAkB,CAACC,OAA+B,CAAA;QAC7DZ,MAAM;QACNa,OAAOD;QACPb,aAAa,CAAC,SAAS,EAAEa,KAAK,6CAA6C,CAAC;IAC9E,CAAA,EAAE;AAEF,OAAO,MAAME,qBAAqB,IAAoB,CAAA;QACpDd,MAAM;QACND,aAAa;QACbgB,YAAY;YACVC,OAAO;gBAAEhB,MAAM;YAAS;YACxBiB,OAAO;gBAAEjB,MAAM;YAAS;YACxBkB,MAAM;gBAAElB,MAAM;YAAS;YACvBmB,QAAQ;gBAAEnB,MAAM;YAAS;YACzBoB,YAAY;gBAAEpB,MAAM;YAAS;YAC7BqB,SAAS;gBAAErB,MAAM;YAAS;QAC5B;QACAU,sBAAsB;IACxB,CAAA,EAAE;AAEF,oCAAoC,GACpC,OAAO,MAAMY,gBAAgB;IAC3BC,SAASzB,SAAS;IAClB0B,SAAS1B,SAAS;AACpB,EAAC;AAED,8EAA8E;AAE9E,OAAO,MAAM2B,sBAAmC;IAC9Cb,MAAM;IACNc,IAAI;IACJC,UAAU;IACV5B,aACE,2EACA,sEACA,6EACA;IACF6B,QAAQ;QAAE5B,MAAM;QAAUC,QAAQ;IAAY;AAChD,EAAC;AAED,mDAAmD,GACnD,OAAO,MAAM4B,aAAa,CACxBjB,MACAb,aACA6B,SAAsB;IAAE5B,MAAM;AAAS,CAAC,GACvB,CAAA;QACjBY;QACAc,IAAI;QACJC,UAAU;QACV5B;QACA6B;IACF,CAAA,EAAE"}
|
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
* What every `/v1` resource handler shares (AGL-2606): the context the
|
|
19
|
+
* pipeline hands it, the scope check, the cursor, serialization and
|
|
20
|
+
* idempotency helpers, and the shape a usage figure is published in.
|
|
21
|
+
*
|
|
22
|
+
* One copy for every resource, whoever serves it. A second copy of the
|
|
23
|
+
* cursor grammar or the idempotency claim in any handler is exactly the drift
|
|
24
|
+
* these exist to prevent: a cursor one resource encodes and another cannot
|
|
25
|
+
* decode, or a claim digest that collides across resources because one copy
|
|
26
|
+
* forgot the org. They are the data layer's because a resource a plugin
|
|
27
|
+
* serves (`api-v1-resources.ts`) is a handler too, and a plugin may not
|
|
28
|
+
* import the console.
|
|
29
|
+
*/
|
|
30
|
+
import { type AttemptClaim } from '@aglyn/aglyn/server';
|
|
31
|
+
import type { AglynOrganization } from '@aglyn/aglyn';
|
|
32
|
+
import type { ApiScope } from './api-keys';
|
|
33
|
+
/** What the pipeline resolved for one authenticated request. */
|
|
34
|
+
export interface ApiV1Context {
|
|
35
|
+
orgId: string;
|
|
36
|
+
keyId: string;
|
|
37
|
+
/**
|
|
38
|
+
* The key's name, as the organization labeled it, or null for a key with
|
|
39
|
+
* none. What a write made with the key is attributed to in the console's
|
|
40
|
+
* activity feeds (AGL-2632) — the key is the actor, and "API key Zapier"
|
|
41
|
+
* is its name the way an address is a person's.
|
|
42
|
+
*/
|
|
43
|
+
keyName: string | null;
|
|
44
|
+
scopes: ApiScope[];
|
|
45
|
+
org: Partial<AglynOrganization>;
|
|
46
|
+
firestore: FirebaseFirestore.Firestore;
|
|
47
|
+
/** Rate-limit headers to echo on every response for this request. */
|
|
48
|
+
headers: Record<string, string>;
|
|
49
|
+
}
|
|
50
|
+
/** Return a 403 unless the key carries `scope`; otherwise `null` (proceed). */
|
|
51
|
+
export declare function requireScope(context: ApiV1Context, scope: ApiScope): Response | null;
|
|
52
|
+
/** Firestore values → JSON-safe values (Timestamps become ISO strings). */
|
|
53
|
+
export declare function serialize(value: unknown): unknown;
|
|
54
|
+
export interface Paginated {
|
|
55
|
+
docs: FirebaseFirestore.QueryDocumentSnapshot[];
|
|
56
|
+
nextCursor: string | null;
|
|
57
|
+
}
|
|
58
|
+
export declare function paginate(query: FirebaseFirestore.Query, url: URL): Promise<Paginated>;
|
|
59
|
+
export declare function readJsonBody(request: Request): Promise<Record<string, unknown>>;
|
|
60
|
+
/**
|
|
61
|
+
* The operations a claim can belong to. Hashed into the digest, so a key
|
|
62
|
+
* reused across two of them is two separate attempts — see `claimWrite`.
|
|
63
|
+
*
|
|
64
|
+
* The CRM pairs (AGL-2606) follow the contact pair's shape: a create and a
|
|
65
|
+
* delete per resource, sharing the organization as their scope suffix and
|
|
66
|
+
* kept apart by the kind alone.
|
|
67
|
+
*/
|
|
68
|
+
export type ClaimKind = 'records' | 'record-deletes' | 'datasets' | 'dataset-deletes' | 'form-submission-deletes' | 'contacts' | 'contact-deletes' | 'contact-merges' | 'media' | 'sites' | 'companies' | 'company-deletes' | 'deals' | 'deal-deletes' | 'tasks' | 'task-deletes' | 'activities' | 'activity-deletes' | 'lead-conversions' | 'lead-creates' | 'email-templates' | 'email-template-deletes';
|
|
69
|
+
/**
|
|
70
|
+
* Take an exclusive claim on one write attempt (AGL-1709), and translate the
|
|
71
|
+
* transport-agnostic result into a v1 `Response`.
|
|
72
|
+
*
|
|
73
|
+
* The claim itself is the shared `claimAttempt` (AGL-1697, `220217133`) — an
|
|
74
|
+
* unconditional `create()`, where Firestore's rejection on an existing document
|
|
75
|
+
* IS the dedupe primitive. This resource used to run its own read-then-create,
|
|
76
|
+
* which is the exact race the mechanism exists to prevent: two concurrent
|
|
77
|
+
* requests carrying one key both read "no prior key", both fall through, both
|
|
78
|
+
* create. Worse than no check at all in one respect, because it made the path
|
|
79
|
+
* look protected. Adopting the shared claim rather than fixing the local copy
|
|
80
|
+
* is the point — POS, refunds and the REST API now answer this question once.
|
|
81
|
+
*
|
|
82
|
+
* `scopeId` carries the ORG as well as the dataset. The helper hashes
|
|
83
|
+
* `{kind}:{scopeId}:{key}` and its plugin callers pass a globally unique host
|
|
84
|
+
* id, but a dataset id is only meaningful under its org — and the claim stores
|
|
85
|
+
* the response body, so a cross-org digest collision would replay one tenant's
|
|
86
|
+
* record to another. The org belongs in the digest, not just in the swept
|
|
87
|
+
* field. Scoping by the DATASET is what `apps/docs/api/conventions.md` already
|
|
88
|
+
* publishes ("replay is looked up within the dataset you're posting to"); the
|
|
89
|
+
* old org-only digest only reached that outcome by accident, by reading a
|
|
90
|
+
* `recordId` that did not exist in the other dataset and falling through — and
|
|
91
|
+
* having fallen through, its key write failed and was swallowed, so the key
|
|
92
|
+
* never deduped there at all.
|
|
93
|
+
*
|
|
94
|
+
* NOTE: the digest changes, so keys stored under the old `orgId:key` shape are
|
|
95
|
+
* orphaned and a retry straddling this deploy creates a duplicate. Accepted —
|
|
96
|
+
* the API is pre-beta and the alternative is carrying the race forever.
|
|
97
|
+
*
|
|
98
|
+
* `kind` separates the create's claims from the delete's (AGL-1710). The
|
|
99
|
+
* helper hashes it into the digest for exactly this reason, and the two record
|
|
100
|
+
* different response BODIES — a record view versus a `{ deleted: true }`
|
|
101
|
+
* receipt. Sharing one namespace would let a key reused across both operations
|
|
102
|
+
* replay the create's record to a delete, which a client parses as a success
|
|
103
|
+
* and which no amount of documentation makes safe. `POST` keeps `records`
|
|
104
|
+
* verbatim: changing its digest orphans keys in flight, and AGL-1709 already
|
|
105
|
+
* paid that cost once.
|
|
106
|
+
*
|
|
107
|
+
* `scopeSuffix` is the object the key is scoped WITHIN — a dataset id for the
|
|
108
|
+
* record operations and for `DELETE /v1/datasets/{id}`, the site for
|
|
109
|
+
* `DELETE …/form-submissions/{id}`, and `*` — the ORGANIZATION — for
|
|
110
|
+
* `POST /v1/datasets` (AGL-2126), where there is no dataset id yet because
|
|
111
|
+
* the request is what creates one, and for both contact writes (AGL-2276),
|
|
112
|
+
* where the organization genuinely IS the containing object: contacts are
|
|
113
|
+
* org-scoped (AGL-237), so there is no site or parent collection to scope to
|
|
114
|
+
* and pretending otherwise would invent a boundary the data does not have.
|
|
115
|
+
* `*` cannot collide with a resource id: `createResourceUid()` never emits
|
|
116
|
+
* one, and the `kind` is separate anyway. The suffix is not the whole story —
|
|
117
|
+
* `kind` is hashed in too, which is what keeps a create's key from replaying
|
|
118
|
+
* into a delete, and what keeps `contacts` and `contact-deletes` apart while
|
|
119
|
+
* they share a suffix.
|
|
120
|
+
*
|
|
121
|
+
* This paragraph used to say `*` covered "the two that act on the dataset
|
|
122
|
+
* collection itself", which the delete has never done — it passes
|
|
123
|
+
* `datasetRef.id`. The published contract in `apps/docs/api/conventions.md` was
|
|
124
|
+
* written from this comment and inherited the error (AGL-2218), which is the
|
|
125
|
+
* argument for reading the call rather than the docblock above it.
|
|
126
|
+
*/
|
|
127
|
+
export declare function claimWrite(ctx: ApiV1Context, scopeSuffix: string, key: string | null, kind: ClaimKind): Promise<{
|
|
128
|
+
claim: AttemptClaim;
|
|
129
|
+
} | {
|
|
130
|
+
replay: Response;
|
|
131
|
+
}>;
|
|
132
|
+
/**
|
|
133
|
+
* One metered dimension, as published (AGL-2277).
|
|
134
|
+
*
|
|
135
|
+
* `included`/`remaining` are `null` for an UNLIMITED band rather than the
|
|
136
|
+
* sentinel itself: `UNLIMITED` is `Number.POSITIVE_INFINITY`, which
|
|
137
|
+
* `JSON.stringify` silently turns into `null` anyway — so the choice is
|
|
138
|
+
* between a `null` that means "unlimited" on purpose and the same `null`
|
|
139
|
+
* arriving by accident, indistinguishable from a bug. Stated explicitly, and
|
|
140
|
+
* documented, so an integrator can branch on it.
|
|
141
|
+
*
|
|
142
|
+
* `metered` is the field that actually answers "what happens when I cross
|
|
143
|
+
* this" — true means the excess bills, false means the next call is refused.
|
|
144
|
+
* It is read off the plan's own overage rate through the `check*Quota`
|
|
145
|
+
* helpers rather than re-derived here, because a second copy of that rule
|
|
146
|
+
* would drift from the one the enforcement path uses, and this endpoint's
|
|
147
|
+
* whole value is telling a customer what the enforcement path will do.
|
|
148
|
+
*/
|
|
149
|
+
export declare function usageBand(used: number, included: number, remaining: number, overageRateUsd: number | null): {
|
|
150
|
+
used: number;
|
|
151
|
+
included: number;
|
|
152
|
+
remaining: number;
|
|
153
|
+
metered: boolean;
|
|
154
|
+
};
|
|
155
|
+
/** Whether `hostId` is one of the authenticated organization's own sites. */
|
|
156
|
+
export declare function orgOwnsHost(ctx: ApiV1Context, hostId: string): boolean;
|
|
@@ -0,0 +1,191 @@
|
|
|
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 every `/v1` resource handler shares (AGL-2606): the context the
|
|
18
|
+
* pipeline hands it, the scope check, the cursor, serialization and
|
|
19
|
+
* idempotency helpers, and the shape a usage figure is published in.
|
|
20
|
+
*
|
|
21
|
+
* One copy for every resource, whoever serves it. A second copy of the
|
|
22
|
+
* cursor grammar or the idempotency claim in any handler is exactly the drift
|
|
23
|
+
* these exist to prevent: a cursor one resource encodes and another cannot
|
|
24
|
+
* decode, or a claim digest that collides across resources because one copy
|
|
25
|
+
* forgot the org. They are the data layer's because a resource a plugin
|
|
26
|
+
* serves (`api-v1-resources.ts`) is a handler too, and a plugin may not
|
|
27
|
+
* import the console.
|
|
28
|
+
*/ import { claimAttempt } from "@aglyn/aglyn/server";
|
|
29
|
+
import { FieldPath, Timestamp } from "firebase-admin/firestore";
|
|
30
|
+
import { apiJson, ApiErrors, decodeCursor, encodeCursor, parseLimit } from "./api-http.js";
|
|
31
|
+
/** Return a 403 unless the key carries `scope`; otherwise `null` (proceed). */ export function requireScope(context, scope) {
|
|
32
|
+
return context.scopes.includes(scope) ? null : ApiErrors.insufficientScope(scope, context.headers);
|
|
33
|
+
}
|
|
34
|
+
// ── Serialization ───────────────────────────────────────────────────────────
|
|
35
|
+
/** Firestore values → JSON-safe values (Timestamps become ISO strings). */ export function serialize(value) {
|
|
36
|
+
if (value instanceof Timestamp) return value.toDate().toISOString();
|
|
37
|
+
if (Array.isArray(value)) return value.map(serialize);
|
|
38
|
+
if (value && typeof value === 'object') {
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const [k, v] of Object.entries(value))out[k] = serialize(v);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
export async function paginate(query, url) {
|
|
46
|
+
const limit = parseLimit(url.searchParams.get('limit'));
|
|
47
|
+
const cursor = decodeCursor(url.searchParams.get('cursor'));
|
|
48
|
+
let q = query.orderBy(FieldPath.documentId()).limit(limit + 1);
|
|
49
|
+
if (cursor) q = q.startAfter(cursor);
|
|
50
|
+
const snap = await q.get();
|
|
51
|
+
const docs = snap.docs.slice(0, limit);
|
|
52
|
+
const nextCursor = snap.docs.length > limit && docs.length > 0 ? encodeCursor(docs[docs.length - 1].id) : null;
|
|
53
|
+
return {
|
|
54
|
+
docs,
|
|
55
|
+
nextCursor
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export async function readJsonBody(request) {
|
|
59
|
+
try {
|
|
60
|
+
const body = await request.json();
|
|
61
|
+
return body && typeof body === 'object' ? body : {};
|
|
62
|
+
} catch (unused) {
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Take an exclusive claim on one write attempt (AGL-1709), and translate the
|
|
68
|
+
* transport-agnostic result into a v1 `Response`.
|
|
69
|
+
*
|
|
70
|
+
* The claim itself is the shared `claimAttempt` (AGL-1697, `220217133`) — an
|
|
71
|
+
* unconditional `create()`, where Firestore's rejection on an existing document
|
|
72
|
+
* IS the dedupe primitive. This resource used to run its own read-then-create,
|
|
73
|
+
* which is the exact race the mechanism exists to prevent: two concurrent
|
|
74
|
+
* requests carrying one key both read "no prior key", both fall through, both
|
|
75
|
+
* create. Worse than no check at all in one respect, because it made the path
|
|
76
|
+
* look protected. Adopting the shared claim rather than fixing the local copy
|
|
77
|
+
* is the point — POS, refunds and the REST API now answer this question once.
|
|
78
|
+
*
|
|
79
|
+
* `scopeId` carries the ORG as well as the dataset. The helper hashes
|
|
80
|
+
* `{kind}:{scopeId}:{key}` and its plugin callers pass a globally unique host
|
|
81
|
+
* id, but a dataset id is only meaningful under its org — and the claim stores
|
|
82
|
+
* the response body, so a cross-org digest collision would replay one tenant's
|
|
83
|
+
* record to another. The org belongs in the digest, not just in the swept
|
|
84
|
+
* field. Scoping by the DATASET is what `apps/docs/api/conventions.md` already
|
|
85
|
+
* publishes ("replay is looked up within the dataset you're posting to"); the
|
|
86
|
+
* old org-only digest only reached that outcome by accident, by reading a
|
|
87
|
+
* `recordId` that did not exist in the other dataset and falling through — and
|
|
88
|
+
* having fallen through, its key write failed and was swallowed, so the key
|
|
89
|
+
* never deduped there at all.
|
|
90
|
+
*
|
|
91
|
+
* NOTE: the digest changes, so keys stored under the old `orgId:key` shape are
|
|
92
|
+
* orphaned and a retry straddling this deploy creates a duplicate. Accepted —
|
|
93
|
+
* the API is pre-beta and the alternative is carrying the race forever.
|
|
94
|
+
*
|
|
95
|
+
* `kind` separates the create's claims from the delete's (AGL-1710). The
|
|
96
|
+
* helper hashes it into the digest for exactly this reason, and the two record
|
|
97
|
+
* different response BODIES — a record view versus a `{ deleted: true }`
|
|
98
|
+
* receipt. Sharing one namespace would let a key reused across both operations
|
|
99
|
+
* replay the create's record to a delete, which a client parses as a success
|
|
100
|
+
* and which no amount of documentation makes safe. `POST` keeps `records`
|
|
101
|
+
* verbatim: changing its digest orphans keys in flight, and AGL-1709 already
|
|
102
|
+
* paid that cost once.
|
|
103
|
+
*
|
|
104
|
+
* `scopeSuffix` is the object the key is scoped WITHIN — a dataset id for the
|
|
105
|
+
* record operations and for `DELETE /v1/datasets/{id}`, the site for
|
|
106
|
+
* `DELETE …/form-submissions/{id}`, and `*` — the ORGANIZATION — for
|
|
107
|
+
* `POST /v1/datasets` (AGL-2126), where there is no dataset id yet because
|
|
108
|
+
* the request is what creates one, and for both contact writes (AGL-2276),
|
|
109
|
+
* where the organization genuinely IS the containing object: contacts are
|
|
110
|
+
* org-scoped (AGL-237), so there is no site or parent collection to scope to
|
|
111
|
+
* and pretending otherwise would invent a boundary the data does not have.
|
|
112
|
+
* `*` cannot collide with a resource id: `createResourceUid()` never emits
|
|
113
|
+
* one, and the `kind` is separate anyway. The suffix is not the whole story —
|
|
114
|
+
* `kind` is hashed in too, which is what keeps a create's key from replaying
|
|
115
|
+
* into a delete, and what keeps `contacts` and `contact-deletes` apart while
|
|
116
|
+
* they share a suffix.
|
|
117
|
+
*
|
|
118
|
+
* This paragraph used to say `*` covered "the two that act on the dataset
|
|
119
|
+
* collection itself", which the delete has never done — it passes
|
|
120
|
+
* `datasetRef.id`. The published contract in `apps/docs/api/conventions.md` was
|
|
121
|
+
* written from this comment and inherited the error (AGL-2218), which is the
|
|
122
|
+
* argument for reading the call rather than the docblock above it.
|
|
123
|
+
*/ export async function claimWrite(ctx, scopeSuffix, key, kind) {
|
|
124
|
+
const result = await claimAttempt(ctx.firestore, {
|
|
125
|
+
kind,
|
|
126
|
+
scopeId: `${ctx.orgId}:${scopeSuffix}`,
|
|
127
|
+
// The field `eraseOrgIdempotencyKeys` sweeps on (AGL-1448).
|
|
128
|
+
orgId: ctx.orgId,
|
|
129
|
+
key: key != null ? key : '',
|
|
130
|
+
busyMessage: 'A request with this Idempotency-Key is still in progress'
|
|
131
|
+
});
|
|
132
|
+
if ('claim' in result) return result;
|
|
133
|
+
// The helper is transport-agnostic and answers `{ status, body }`; its
|
|
134
|
+
// plugin callers hand that straight to a pages-API `res.json()`. v1 publishes
|
|
135
|
+
// a `{ error: { type, message, code } }` envelope that clients branch on, so
|
|
136
|
+
// the refusal is rebuilt here rather than leaking the helper's bare
|
|
137
|
+
// `{ error: '<sentence>' }` onto a documented surface.
|
|
138
|
+
if (result.replay.status === 409) {
|
|
139
|
+
return {
|
|
140
|
+
replay: ApiErrors.conflict({
|
|
141
|
+
message: 'A request with this Idempotency-Key is still in progress',
|
|
142
|
+
code: 'idempotency_in_progress',
|
|
143
|
+
headers: ctx.headers
|
|
144
|
+
})
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
// Settled: replay the ORIGINAL response, from the stored body rather than a
|
|
148
|
+
// fresh read — which is what makes it survive a record since edited or
|
|
149
|
+
// deleted. The old lookup replayed only while the record still existed and
|
|
150
|
+
// fell through to a SECOND create otherwise.
|
|
151
|
+
return {
|
|
152
|
+
replay: apiJson(result.replay.body, {
|
|
153
|
+
status: result.replay.status,
|
|
154
|
+
headers: ctx.headers
|
|
155
|
+
})
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
// ── Usage ───────────────────────────────────────────────────────────────────
|
|
159
|
+
/**
|
|
160
|
+
* One metered dimension, as published (AGL-2277).
|
|
161
|
+
*
|
|
162
|
+
* `included`/`remaining` are `null` for an UNLIMITED band rather than the
|
|
163
|
+
* sentinel itself: `UNLIMITED` is `Number.POSITIVE_INFINITY`, which
|
|
164
|
+
* `JSON.stringify` silently turns into `null` anyway — so the choice is
|
|
165
|
+
* between a `null` that means "unlimited" on purpose and the same `null`
|
|
166
|
+
* arriving by accident, indistinguishable from a bug. Stated explicitly, and
|
|
167
|
+
* documented, so an integrator can branch on it.
|
|
168
|
+
*
|
|
169
|
+
* `metered` is the field that actually answers "what happens when I cross
|
|
170
|
+
* this" — true means the excess bills, false means the next call is refused.
|
|
171
|
+
* It is read off the plan's own overage rate through the `check*Quota`
|
|
172
|
+
* helpers rather than re-derived here, because a second copy of that rule
|
|
173
|
+
* would drift from the one the enforcement path uses, and this endpoint's
|
|
174
|
+
* whole value is telling a customer what the enforcement path will do.
|
|
175
|
+
*/ export function usageBand(used, included, remaining, overageRateUsd) {
|
|
176
|
+
const unlimited = !Number.isFinite(included);
|
|
177
|
+
return {
|
|
178
|
+
used,
|
|
179
|
+
included: unlimited ? null : included,
|
|
180
|
+
remaining: unlimited ? null : remaining,
|
|
181
|
+
metered: overageRateUsd !== null
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
// ── Ownership ───────────────────────────────────────────────────────────────
|
|
185
|
+
/** Whether `hostId` is one of the authenticated organization's own sites. */ export function orgOwnsHost(ctx, hostId) {
|
|
186
|
+
var _ctx_org_hosts;
|
|
187
|
+
const hosts = (_ctx_org_hosts = ctx.org.hosts) != null ? _ctx_org_hosts : {};
|
|
188
|
+
return Boolean(hosts[hostId]);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
//# sourceMappingURL=api-v1-kit.js.map
|