@aglyn/plugins-data 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 +11 -9
- package/src/lib/constants/bundle-common.d.ts +6 -0
- package/src/lib/constants/bundle-common.js +5 -0
- package/src/lib/constants/bundle-common.js.map +1 -1
- package/src/lib/declarations.server.d.ts +39 -0
- package/src/lib/declarations.server.js +63 -0
- package/src/lib/declarations.server.js.map +1 -0
- package/src/lib/form-target/dataset-form-record-target.server.d.ts +40 -0
- package/src/lib/form-target/dataset-form-record-target.server.js +160 -0
- package/src/lib/form-target/dataset-form-record-target.server.js.map +1 -0
- package/src/lib/form-target/form-dataset-binding-token.d.ts +35 -0
- package/src/lib/form-target/form-dataset-binding-token.js +107 -0
- package/src/lib/form-target/form-dataset-binding-token.js.map +1 -0
- package/src/lib/form-target/form-dataset-binding.d.ts +50 -0
- package/src/lib/form-target/form-dataset-binding.js +74 -0
- package/src/lib/form-target/form-dataset-binding.js.map +1 -0
- package/src/lib/form-target/stamp-form-dataset-bindings.d.ts +39 -0
- package/src/lib/form-target/stamp-form-dataset-bindings.js +65 -0
- package/src/lib/form-target/stamp-form-dataset-bindings.js.map +1 -0
- package/src/lib/repeat/dataset-repeat-rows.d.ts +2 -2
- package/src/lib/repeat/dataset-repeat-rows.js +2 -2
- package/src/lib/repeat/dataset-repeat-rows.js.map +1 -1
- package/src/lib/repeat/dataset-repeat-rows.server.d.ts +43 -0
- package/src/lib/repeat/dataset-repeat-rows.server.js +170 -0
- package/src/lib/repeat/dataset-repeat-rows.server.js.map +1 -0
- package/src/lib/repeat/dataset-repeat-source.js +3 -1
- package/src/lib/repeat/dataset-repeat-source.js.map +1 -1
- package/src/lib/site-bundle/datasets-site-bundle.server.d.ts +87 -0
- package/src/lib/site-bundle/datasets-site-bundle.server.js +197 -0
- package/src/lib/site-bundle/datasets-site-bundle.server.js.map +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/plugins-data",
|
|
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,19 +25,21 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-ui-snackstack": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
34
|
-
"@aglyn/tenant-
|
|
35
|
-
"@aglyn/tenant-
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.219",
|
|
29
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.219",
|
|
30
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.219",
|
|
31
|
+
"@aglyn/shared-ui-snackstack": "1.0.0-beta.219",
|
|
32
|
+
"@aglyn/shared-util-http": "1.0.0-beta.219",
|
|
33
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.219",
|
|
34
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.219",
|
|
35
|
+
"@aglyn/tenant-feature-instance": "1.0.0-beta.219",
|
|
36
|
+
"@aglyn/tenant-runtime": "1.0.0-beta.219",
|
|
36
37
|
"@swc/helpers": "0.5.23"
|
|
37
38
|
},
|
|
38
39
|
"peerDependencies": {
|
|
39
40
|
"@mui/material": "^9.3.1",
|
|
40
41
|
"firebase": "^12.18.0",
|
|
42
|
+
"firebase-admin": "^14.3.0",
|
|
41
43
|
"react": "^19.2.8"
|
|
42
44
|
},
|
|
43
45
|
"sideEffects": false,
|
|
@@ -6,3 +6,9 @@
|
|
|
6
6
|
* you may not use this file except in compliance with the License.
|
|
7
7
|
*/
|
|
8
8
|
export declare const BUNDLE_ID = "data";
|
|
9
|
+
/**
|
|
10
|
+
* The id datasets are a repeat source under — the editor's source
|
|
11
|
+
* (`repeat-sources.ts`) and the published page's reader (`repeat-rows.ts`)
|
|
12
|
+
* alike, and the `repeatSource` this plugin declares in `plugins.config.json`.
|
|
13
|
+
*/
|
|
14
|
+
export declare const DATASET_REPEAT_SOURCE_ID = "dataset";
|
|
@@ -5,5 +5,10 @@
|
|
|
5
5
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
6
|
* you may not use this file except in compliance with the License.
|
|
7
7
|
*/ export const BUNDLE_ID = 'data';
|
|
8
|
+
/**
|
|
9
|
+
* The id datasets are a repeat source under — the editor's source
|
|
10
|
+
* (`repeat-sources.ts`) and the published page's reader (`repeat-rows.ts`)
|
|
11
|
+
* alike, and the `repeatSource` this plugin declares in `plugins.config.json`.
|
|
12
|
+
*/ export const DATASET_REPEAT_SOURCE_ID = 'dataset';
|
|
8
13
|
|
|
9
14
|
//# sourceMappingURL=bundle-common.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/data/src/lib/constants/bundle-common.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 */\n\nexport const BUNDLE_ID = 'data'\n"],"names":["BUNDLE_ID"],"mappings":"AAAA;;;;;;CAMC,GAED,OAAO,MAAMA,YAAY,OAAM"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/data/src/lib/constants/bundle-common.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 */\n\nexport const BUNDLE_ID = 'data'\n\n/**\n * The id datasets are a repeat source under — the editor's source\n * (`repeat-sources.ts`) and the published page's reader (`repeat-rows.ts`)\n * alike, and the `repeatSource` this plugin declares in `plugins.config.json`.\n */\nexport const DATASET_REPEAT_SOURCE_ID = 'dataset'\n"],"names":["BUNDLE_ID","DATASET_REPEAT_SOURCE_ID"],"mappings":"AAAA;;;;;;CAMC,GAED,OAAO,MAAMA,YAAY,OAAM;AAE/B;;;;CAIC,GACD,OAAO,MAAMC,2BAA2B,UAAS"}
|
|
@@ -0,0 +1,39 @@
|
|
|
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
|
+
* The data plugin's server declarations: the light registrations core reads
|
|
19
|
+
* at boot, before any surface loads.
|
|
20
|
+
*
|
|
21
|
+
* A published page that repeats an element over a dataset asks the platform
|
|
22
|
+
* for the rows, and this is who answers — in every process that composes a
|
|
23
|
+
* page, the tenant's and the console's previews alike. The reader, and the
|
|
24
|
+
* Admin SDK with it, loads on the first page that repeats, not at boot.
|
|
25
|
+
* `plugins.config.json` declares the same source (`repeatSource`), so a boot
|
|
26
|
+
* that skipped this is refused rather than rendered as "no rows".
|
|
27
|
+
*
|
|
28
|
+
* A form can also file every submission as a dataset record, and this is the
|
|
29
|
+
* target that stamps such a form with its signed binding when a page renders
|
|
30
|
+
* and writes the record when the submission comes back. Declared the same way
|
|
31
|
+
* (`formRecordTarget`), and just as light: both halves load on first use.
|
|
32
|
+
*
|
|
33
|
+
* A site's datasets are also its section of the whole-site backup: the
|
|
34
|
+
* console's export and restore ask this plugin for them. Declared too
|
|
35
|
+
* (`siteBundleSections`), so a boot that skipped this fails the export rather
|
|
36
|
+
* than shipping a backup without them; the answers load when a backup is made
|
|
37
|
+
* or restored.
|
|
38
|
+
*/
|
|
39
|
+
export declare function registerDataServerDeclarations(): void;
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
*/ // The seam from its own module, not the plugin-manager barrel: boot needs the
|
|
17
|
+
// registry and nothing else, and the barrel reaches the client contexts.
|
|
18
|
+
import { registerFormRecordTarget } from "@aglyn/aglyn/plugin-manager/submission-record-target";
|
|
19
|
+
import { registerRepeatRowReader } from "@aglyn/aglyn/plugin-manager/repeat-rows";
|
|
20
|
+
import { registerPluginSiteBundleSection } from "@aglyn/aglyn/plugin-manager/plugin-site-bundle";
|
|
21
|
+
import { BUNDLE_ID, DATASET_REPEAT_SOURCE_ID } from "./constants/bundle-common.js";
|
|
22
|
+
/**
|
|
23
|
+
* The data plugin's server declarations: the light registrations core reads
|
|
24
|
+
* at boot, before any surface loads.
|
|
25
|
+
*
|
|
26
|
+
* A published page that repeats an element over a dataset asks the platform
|
|
27
|
+
* for the rows, and this is who answers — in every process that composes a
|
|
28
|
+
* page, the tenant's and the console's previews alike. The reader, and the
|
|
29
|
+
* Admin SDK with it, loads on the first page that repeats, not at boot.
|
|
30
|
+
* `plugins.config.json` declares the same source (`repeatSource`), so a boot
|
|
31
|
+
* that skipped this is refused rather than rendered as "no rows".
|
|
32
|
+
*
|
|
33
|
+
* A form can also file every submission as a dataset record, and this is the
|
|
34
|
+
* target that stamps such a form with its signed binding when a page renders
|
|
35
|
+
* and writes the record when the submission comes back. Declared the same way
|
|
36
|
+
* (`formRecordTarget`), and just as light: both halves load on first use.
|
|
37
|
+
*
|
|
38
|
+
* A site's datasets are also its section of the whole-site backup: the
|
|
39
|
+
* console's export and restore ask this plugin for them. Declared too
|
|
40
|
+
* (`siteBundleSections`), so a boot that skipped this fails the export rather
|
|
41
|
+
* than shipping a backup without them; the answers load when a backup is made
|
|
42
|
+
* or restored.
|
|
43
|
+
*/ export function registerDataServerDeclarations() {
|
|
44
|
+
registerRepeatRowReader(DATASET_REPEAT_SOURCE_ID, async (request)=>(await import("./repeat/dataset-repeat-rows.server.js")).readPublishedDatasetRows(request), {
|
|
45
|
+
pluginId: BUNDLE_ID
|
|
46
|
+
});
|
|
47
|
+
registerFormRecordTarget({
|
|
48
|
+
stamp: async (nodes, hostId)=>(await import("./form-target/stamp-form-dataset-bindings.js")).stampFormDatasetBindings(nodes, hostId),
|
|
49
|
+
write: async (request)=>(await import("./form-target/dataset-form-record-target.server.js")).writeFormSubmissionRecord(request)
|
|
50
|
+
}, {
|
|
51
|
+
pluginId: BUNDLE_ID
|
|
52
|
+
});
|
|
53
|
+
const datasets = ()=>import("./site-bundle/datasets-site-bundle.server.js");
|
|
54
|
+
registerPluginSiteBundleSection('datasets', {
|
|
55
|
+
export: async (request)=>(await datasets()).exportSiteDatasets(request),
|
|
56
|
+
refusal: async (request)=>(await datasets()).siteDatasetsRefusal(request),
|
|
57
|
+
import: async (request)=>(await datasets()).importSiteDatasets(request)
|
|
58
|
+
}, {
|
|
59
|
+
pluginId: BUNDLE_ID
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
//# sourceMappingURL=declarations.server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/plugins/data/src/lib/declarations.server.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// The seam from its own module, not the plugin-manager barrel: boot needs the\n// registry and nothing else, and the barrel reaches the client contexts.\nimport { registerFormRecordTarget } from '@aglyn/aglyn/plugin-manager/submission-record-target'\nimport { registerRepeatRowReader } from '@aglyn/aglyn/plugin-manager/repeat-rows'\nimport { registerPluginSiteBundleSection } from '@aglyn/aglyn/plugin-manager/plugin-site-bundle'\nimport { BUNDLE_ID, DATASET_REPEAT_SOURCE_ID } from './constants/bundle-common'\n\n/**\n * The data plugin's server declarations: the light registrations core reads\n * at boot, before any surface loads.\n *\n * A published page that repeats an element over a dataset asks the platform\n * for the rows, and this is who answers — in every process that composes a\n * page, the tenant's and the console's previews alike. The reader, and the\n * Admin SDK with it, loads on the first page that repeats, not at boot.\n * `plugins.config.json` declares the same source (`repeatSource`), so a boot\n * that skipped this is refused rather than rendered as \"no rows\".\n *\n * A form can also file every submission as a dataset record, and this is the\n * target that stamps such a form with its signed binding when a page renders\n * and writes the record when the submission comes back. Declared the same way\n * (`formRecordTarget`), and just as light: both halves load on first use.\n *\n * A site's datasets are also its section of the whole-site backup: the\n * console's export and restore ask this plugin for them. Declared too\n * (`siteBundleSections`), so a boot that skipped this fails the export rather\n * than shipping a backup without them; the answers load when a backup is made\n * or restored.\n */\nexport function registerDataServerDeclarations(): void {\n registerRepeatRowReader(\n DATASET_REPEAT_SOURCE_ID,\n async (request) =>\n (await import('./repeat/dataset-repeat-rows.server')).readPublishedDatasetRows(\n request,\n ),\n { pluginId: BUNDLE_ID },\n )\n registerFormRecordTarget(\n {\n stamp: async (nodes, hostId) =>\n (await import('./form-target/stamp-form-dataset-bindings')).stampFormDatasetBindings(\n nodes,\n hostId,\n ),\n write: async (request) =>\n (await import('./form-target/dataset-form-record-target.server')).writeFormSubmissionRecord(\n request,\n ),\n },\n { pluginId: BUNDLE_ID },\n )\n const datasets = () => import('./site-bundle/datasets-site-bundle.server')\n registerPluginSiteBundleSection(\n 'datasets',\n {\n export: async (request) => (await datasets()).exportSiteDatasets(request),\n refusal: async (request) => (await datasets()).siteDatasetsRefusal(request),\n import: async (request) => (await datasets()).importSiteDatasets(request),\n },\n { pluginId: BUNDLE_ID },\n )\n}\n"],"names":["registerFormRecordTarget","registerRepeatRowReader","registerPluginSiteBundleSection","BUNDLE_ID","DATASET_REPEAT_SOURCE_ID","registerDataServerDeclarations","request","readPublishedDatasetRows","pluginId","stamp","nodes","hostId","stampFormDatasetBindings","write","writeFormSubmissionRecord","datasets","export","exportSiteDatasets","refusal","siteDatasetsRefusal","import","importSiteDatasets"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,8EAA8E;AAC9E,yEAAyE;AACzE,SAASA,wBAAwB,QAAQ,uDAAsD;AAC/F,SAASC,uBAAuB,QAAQ,0CAAyC;AACjF,SAASC,+BAA+B,QAAQ,iDAAgD;AAChG,SAASC,SAAS,EAAEC,wBAAwB,QAAQ,+BAA2B;AAE/E;;;;;;;;;;;;;;;;;;;;;CAqBC,GACD,OAAO,SAASC;IACdJ,wBACEG,0BACA,OAAOE,UACL,AAAC,CAAA,MAAM,MAAM,CAAC,yCAAqC,EAAGC,wBAAwB,CAC5ED,UAEJ;QAAEE,UAAUL;IAAU;IAExBH,yBACE;QACES,OAAO,OAAOC,OAAOC,SACnB,AAAC,CAAA,MAAM,MAAM,CAAC,+CAA2C,EAAGC,wBAAwB,CAClFF,OACAC;QAEJE,OAAO,OAAOP,UACZ,AAAC,CAAA,MAAM,MAAM,CAAC,qDAAiD,EAAGQ,yBAAyB,CACzFR;IAEN,GACA;QAAEE,UAAUL;IAAU;IAExB,MAAMY,WAAW,IAAM,MAAM,CAAC;IAC9Bb,gCACE,YACA;QACEc,QAAQ,OAAOV,UAAY,AAAC,CAAA,MAAMS,UAAS,EAAGE,kBAAkB,CAACX;QACjEY,SAAS,OAAOZ,UAAY,AAAC,CAAA,MAAMS,UAAS,EAAGI,mBAAmB,CAACb;QACnEc,QAAQ,OAAOd,UAAY,AAAC,CAAA,MAAMS,UAAS,EAAGM,kBAAkB,CAACf;IACnE,GACA;QAAEE,UAAUL;IAAU;AAE1B"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { FormRecordWriteOutcome, FormRecordWriteRequest } from '@aglyn/aglyn/plugin-manager/submission-record-target';
|
|
18
|
+
/**
|
|
19
|
+
* THE DATASET A SUBMISSION ALSO WRITES A RECORD TO (AGL-141/556), decided on
|
|
20
|
+
* the server — this plugin's answer to the platform's contract
|
|
21
|
+
* (`plugin-manager/submission-record-target.ts`), asked by the submit route
|
|
22
|
+
* after the submission itself is stored.
|
|
23
|
+
*
|
|
24
|
+
* The body is public and unauthenticated, so nothing in it may choose where a
|
|
25
|
+
* record lands: a dataset id or field map taken from it let anyone add rows to
|
|
26
|
+
* any dataset the site can see and send any submitted value into any of its
|
|
27
|
+
* fields. The binding is the one the page's compose read off this form and
|
|
28
|
+
* signed (`stampFormDatasetBindings`): for this site only, and exactly what the
|
|
29
|
+
* form's own props and fields declare, so a form writes where it always wrote.
|
|
30
|
+
* Without a valid signature there is no record. The Inbox copy is canonical
|
|
31
|
+
* either way.
|
|
32
|
+
*
|
|
33
|
+
* What it answers is the submission's `routing`: `dataset` — where the record
|
|
34
|
+
* went — or `datasetRefused` — which values the dataset could not hold. Both
|
|
35
|
+
* are read by the Inbox.
|
|
36
|
+
*
|
|
37
|
+
* Best-effort: a missing dataset or a full record quota never fails the
|
|
38
|
+
* submission, and never claims a row that was not written.
|
|
39
|
+
*/
|
|
40
|
+
export declare function writeFormSubmissionRecord(request: FormRecordWriteRequest): Promise<FormRecordWriteOutcome>;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { checkQuota, datasetIntegrityFields, effectiveDatasetModel, ensureDeclaredCustomFieldTypes, prepareDatasetRecordWrite } from "@aglyn/aglyn/server";
|
|
18
|
+
import { dataStorageRefusal, firebaseAdmin, orgDataCollectionForHost } from "@aglyn/tenant-data-admin";
|
|
19
|
+
import { announceDatasetRecordChange } from "@aglyn/tenant-data-admin/server/dataset-live-pages";
|
|
20
|
+
import { resolveDatasetDoc } from "@aglyn/tenant-runtime/resolve-dataset";
|
|
21
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
22
|
+
import { verifyFormDatasetBinding } from "./form-dataset-binding-token.js";
|
|
23
|
+
import { FORM_DATASET_BINDING_FIELD } from "./stamp-form-dataset-bindings.js";
|
|
24
|
+
/**
|
|
25
|
+
* THE DATASET A SUBMISSION ALSO WRITES A RECORD TO (AGL-141/556), decided on
|
|
26
|
+
* the server — this plugin's answer to the platform's contract
|
|
27
|
+
* (`plugin-manager/submission-record-target.ts`), asked by the submit route
|
|
28
|
+
* after the submission itself is stored.
|
|
29
|
+
*
|
|
30
|
+
* The body is public and unauthenticated, so nothing in it may choose where a
|
|
31
|
+
* record lands: a dataset id or field map taken from it let anyone add rows to
|
|
32
|
+
* any dataset the site can see and send any submitted value into any of its
|
|
33
|
+
* fields. The binding is the one the page's compose read off this form and
|
|
34
|
+
* signed (`stampFormDatasetBindings`): for this site only, and exactly what the
|
|
35
|
+
* form's own props and fields declare, so a form writes where it always wrote.
|
|
36
|
+
* Without a valid signature there is no record. The Inbox copy is canonical
|
|
37
|
+
* either way.
|
|
38
|
+
*
|
|
39
|
+
* What it answers is the submission's `routing`: `dataset` — where the record
|
|
40
|
+
* went — or `datasetRefused` — which values the dataset could not hold. Both
|
|
41
|
+
* are read by the Inbox.
|
|
42
|
+
*
|
|
43
|
+
* Best-effort: a missing dataset or a full record quota never fails the
|
|
44
|
+
* submission, and never claims a row that was not written.
|
|
45
|
+
*/ export async function writeFormSubmissionRecord(request) {
|
|
46
|
+
const { hostId, orgId, fields } = request;
|
|
47
|
+
const orgBilling = request.orgBilling;
|
|
48
|
+
const binding = verifyFormDatasetBinding(hostId, request.body[FORM_DATASET_BINDING_FIELD]);
|
|
49
|
+
if (!binding) return {};
|
|
50
|
+
try {
|
|
51
|
+
var _ref, _ref1, _datasetDoc_get;
|
|
52
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
53
|
+
// Org-scoped datasets (AGL-237): the form's dataset resolves against the
|
|
54
|
+
// org so every host shares it.
|
|
55
|
+
const datasetsRef = await orgDataCollectionForHost(hostId, 'datasets');
|
|
56
|
+
const datasetDoc = await resolveDatasetDoc(datasetsRef, {
|
|
57
|
+
datasetId: binding.datasetId,
|
|
58
|
+
datasetName: binding.datasetName
|
|
59
|
+
}, hostId);
|
|
60
|
+
if (!(datasetDoc == null ? void 0 : datasetDoc.exists) || datasetDoc.get('deletedAt')) return {};
|
|
61
|
+
const bound = {
|
|
62
|
+
model: datasetDoc.get('model'),
|
|
63
|
+
fields: Array.isArray(datasetDoc.get('fields')) ? datasetDoc.get('fields') : []
|
|
64
|
+
};
|
|
65
|
+
const boundModel = effectiveDatasetModel(bound);
|
|
66
|
+
// `displayName` first, then the legacy `name`, then the name the form was
|
|
67
|
+
// bound by — the same precedence `findDatasetByName` resolves in. Reading
|
|
68
|
+
// only `name` would leave every modern dataset's chip unnamed.
|
|
69
|
+
const datasetLabel = String((_ref = (_ref1 = (_datasetDoc_get = datasetDoc.get('displayName')) != null ? _datasetDoc_get : datasetDoc.get('name')) != null ? _ref1 : binding.datasetName) != null ? _ref : '').slice(0, 60);
|
|
70
|
+
/*
|
|
71
|
+
* CHECKED AGAINST THE MODEL (AGL-2773, option B). Each value is coerced to
|
|
72
|
+
* its field's type and held to the field's rules, the same pair the
|
|
73
|
+
* console and `/v1` run, custom field types included — so a plugin's
|
|
74
|
+
* validator has to be registered first.
|
|
75
|
+
*
|
|
76
|
+
* A refused record is not written, and the submission is kept: the Inbox
|
|
77
|
+
* copy is the visitor's, and a value the dataset cannot hold is no reason
|
|
78
|
+
* to lose it. The refusal is noted on the submission, per field, so the
|
|
79
|
+
* Inbox can say why the row is missing instead of leaving the owner to
|
|
80
|
+
* guess.
|
|
81
|
+
*/ await ensureDeclaredCustomFieldTypes(boundModel);
|
|
82
|
+
const write = prepareDatasetRecordWrite(bound, fields, {
|
|
83
|
+
fieldMap: binding.fieldMap
|
|
84
|
+
});
|
|
85
|
+
if (Object.keys(write.errors).length) {
|
|
86
|
+
return {
|
|
87
|
+
routing: {
|
|
88
|
+
datasetRefused: {
|
|
89
|
+
id: datasetDoc.id,
|
|
90
|
+
name: datasetLabel,
|
|
91
|
+
errors: write.errors
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const values = write.values;
|
|
97
|
+
if (!Object.keys(values).length) return {};
|
|
98
|
+
const recordCount = (await datasetDoc.ref.collection('records').count().get()).data().count;
|
|
99
|
+
if (!checkQuota(orgBilling, 'recordsPerDataset', recordCount).allowed) {
|
|
100
|
+
return {};
|
|
101
|
+
}
|
|
102
|
+
/*
|
|
103
|
+
* BYTES, not just rows (AGL-2253). This leg checked `recordsPerDataset`
|
|
104
|
+
* and never `dataStorageMbPerOrg`, so a public form wired to a dataset
|
|
105
|
+
* wrote past a byte band the console route hard-blocks at — the one
|
|
106
|
+
* dataset-writing path a visitor can drive without an account, and
|
|
107
|
+
* therefore the one where the volume is not the customer's to control.
|
|
108
|
+
*
|
|
109
|
+
* Costs nothing on a metered plan: `dataStorageRefusal` answers `null`
|
|
110
|
+
* with no read whenever the plan carries an `extraDataGbMonthlyUsd` rate,
|
|
111
|
+
* which is every plan that sells the data store. The read is paid only on
|
|
112
|
+
* the two unmetered shapes.
|
|
113
|
+
*
|
|
114
|
+
* Refused like the row quota above — the submission still lands in the
|
|
115
|
+
* Inbox, and `routing.dataset` is not stamped. A lost lead is the worse
|
|
116
|
+
* error here.
|
|
117
|
+
*/ if (orgId && await dataStorageRefusal(orgBilling, firestore.collection('orgs').doc(orgId))) {
|
|
118
|
+
return {};
|
|
119
|
+
}
|
|
120
|
+
const recordRef = await datasetDoc.ref.collection('records').add(_extends({
|
|
121
|
+
values
|
|
122
|
+
}, datasetIntegrityFields(boundModel, values), {
|
|
123
|
+
createdAt: FieldValue.serverTimestamp()
|
|
124
|
+
}));
|
|
125
|
+
/*
|
|
126
|
+
* The pages repeating over the dataset are refreshed (AGL-3113).
|
|
127
|
+
*
|
|
128
|
+
* This is the path the Datasets page's own claim rests on — bind an
|
|
129
|
+
* element to a dataset, publish, and a record written later shows within
|
|
130
|
+
* seconds. Best effort: a refusal from the cache must never turn a stored
|
|
131
|
+
* lead into a lost one.
|
|
132
|
+
*/ if (orgId) {
|
|
133
|
+
await announceDatasetRecordChange({
|
|
134
|
+
firestore,
|
|
135
|
+
orgId,
|
|
136
|
+
datasetId: datasetDoc.id
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/*
|
|
140
|
+
* Provenance (AGL-2168). `/product/forms`'s hero mockup shows the detail
|
|
141
|
+
* pane carrying `Added to "Leads" dataset` under the fields, so the record
|
|
142
|
+
* is named on the submission — only on this path, where a record was
|
|
143
|
+
* really created. A chip claiming a row that a deleted dataset or a full
|
|
144
|
+
* quota refused would be worse than the silence it replaced.
|
|
145
|
+
*/ return {
|
|
146
|
+
routing: {
|
|
147
|
+
dataset: {
|
|
148
|
+
id: datasetDoc.id,
|
|
149
|
+
name: datasetLabel,
|
|
150
|
+
recordId: recordRef.id
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
} catch (error) {
|
|
155
|
+
console.error('form dataset append failed', error);
|
|
156
|
+
return {};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
//# sourceMappingURL=dataset-form-record-target.server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/data/src/lib/form-target/dataset-form-record-target.server.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 checkQuota,\n datasetIntegrityFields,\n effectiveDatasetModel,\n ensureDeclaredCustomFieldTypes,\n prepareDatasetRecordWrite,\n} from '@aglyn/aglyn/server'\nimport type {\n FormRecordWriteOutcome,\n FormRecordWriteRequest,\n} from '@aglyn/aglyn/plugin-manager/submission-record-target'\nimport {\n dataStorageRefusal,\n firebaseAdmin,\n orgDataCollectionForHost,\n} from '@aglyn/tenant-data-admin'\nimport { announceDatasetRecordChange } from '@aglyn/tenant-data-admin/server/dataset-live-pages'\nimport { resolveDatasetDoc } from '@aglyn/tenant-runtime/resolve-dataset'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { verifyFormDatasetBinding } from './form-dataset-binding-token'\nimport { FORM_DATASET_BINDING_FIELD } from './stamp-form-dataset-bindings'\n\n/**\n * THE DATASET A SUBMISSION ALSO WRITES A RECORD TO (AGL-141/556), decided on\n * the server — this plugin's answer to the platform's contract\n * (`plugin-manager/submission-record-target.ts`), asked by the submit route\n * after the submission itself is stored.\n *\n * The body is public and unauthenticated, so nothing in it may choose where a\n * record lands: a dataset id or field map taken from it let anyone add rows to\n * any dataset the site can see and send any submitted value into any of its\n * fields. The binding is the one the page's compose read off this form and\n * signed (`stampFormDatasetBindings`): for this site only, and exactly what the\n * form's own props and fields declare, so a form writes where it always wrote.\n * Without a valid signature there is no record. The Inbox copy is canonical\n * either way.\n *\n * What it answers is the submission's `routing`: `dataset` — where the record\n * went — or `datasetRefused` — which values the dataset could not hold. Both\n * are read by the Inbox.\n *\n * Best-effort: a missing dataset or a full record quota never fails the\n * submission, and never claims a row that was not written.\n */\nexport async function writeFormSubmissionRecord(\n request: FormRecordWriteRequest,\n): Promise<FormRecordWriteOutcome> {\n const { hostId, orgId, fields } = request\n const orgBilling = request.orgBilling as Record<string, unknown> | undefined\n const binding = verifyFormDatasetBinding(\n hostId,\n request.body[FORM_DATASET_BINDING_FIELD],\n )\n if (!binding) return {}\n try {\n const firestore = firebaseAdmin.app().firestore()\n // Org-scoped datasets (AGL-237): the form's dataset resolves against the\n // org so every host shares it.\n const datasetsRef = await orgDataCollectionForHost(hostId, 'datasets')\n const datasetDoc = await resolveDatasetDoc(\n datasetsRef,\n { datasetId: binding.datasetId, datasetName: binding.datasetName },\n hostId,\n )\n if (!datasetDoc?.exists || datasetDoc.get('deletedAt')) return {}\n const bound = {\n model: datasetDoc.get('model'),\n fields: Array.isArray(datasetDoc.get('fields'))\n ? datasetDoc.get('fields')\n : [],\n }\n const boundModel = effectiveDatasetModel(bound)\n // `displayName` first, then the legacy `name`, then the name the form was\n // bound by — the same precedence `findDatasetByName` resolves in. Reading\n // only `name` would leave every modern dataset's chip unnamed.\n const datasetLabel = String(\n datasetDoc.get('displayName') ??\n datasetDoc.get('name') ??\n binding.datasetName ??\n '',\n ).slice(0, 60)\n /*\n * CHECKED AGAINST THE MODEL (AGL-2773, option B). Each value is coerced to\n * its field's type and held to the field's rules, the same pair the\n * console and `/v1` run, custom field types included — so a plugin's\n * validator has to be registered first.\n *\n * A refused record is not written, and the submission is kept: the Inbox\n * copy is the visitor's, and a value the dataset cannot hold is no reason\n * to lose it. The refusal is noted on the submission, per field, so the\n * Inbox can say why the row is missing instead of leaving the owner to\n * guess.\n */\n await ensureDeclaredCustomFieldTypes(boundModel)\n const write = prepareDatasetRecordWrite(bound, fields, {\n fieldMap: binding.fieldMap,\n })\n if (Object.keys(write.errors).length) {\n return {\n routing: {\n datasetRefused: {\n id: datasetDoc.id,\n name: datasetLabel,\n errors: write.errors,\n },\n },\n }\n }\n const values = write.values\n if (!Object.keys(values).length) return {}\n const recordCount = (\n await datasetDoc.ref.collection('records').count().get()\n ).data().count\n if (\n !checkQuota(orgBilling as never, 'recordsPerDataset', recordCount).allowed\n ) {\n return {}\n }\n /*\n * BYTES, not just rows (AGL-2253). This leg checked `recordsPerDataset`\n * and never `dataStorageMbPerOrg`, so a public form wired to a dataset\n * wrote past a byte band the console route hard-blocks at — the one\n * dataset-writing path a visitor can drive without an account, and\n * therefore the one where the volume is not the customer's to control.\n *\n * Costs nothing on a metered plan: `dataStorageRefusal` answers `null`\n * with no read whenever the plan carries an `extraDataGbMonthlyUsd` rate,\n * which is every plan that sells the data store. The read is paid only on\n * the two unmetered shapes.\n *\n * Refused like the row quota above — the submission still lands in the\n * Inbox, and `routing.dataset` is not stamped. A lost lead is the worse\n * error here.\n */\n if (\n orgId &&\n (await dataStorageRefusal(\n orgBilling as never,\n firestore.collection('orgs').doc(orgId),\n ))\n ) {\n return {}\n }\n const recordRef = await datasetDoc.ref.collection('records').add({\n values,\n // The integrity index the console's delete check queries. A submission\n // can land in a reference field through a bound form, so this leg\n // carries it like every other write that sets `values`.\n ...datasetIntegrityFields(boundModel, values),\n createdAt: FieldValue.serverTimestamp(),\n })\n /*\n * The pages repeating over the dataset are refreshed (AGL-3113).\n *\n * This is the path the Datasets page's own claim rests on — bind an\n * element to a dataset, publish, and a record written later shows within\n * seconds. Best effort: a refusal from the cache must never turn a stored\n * lead into a lost one.\n */\n if (orgId) {\n await announceDatasetRecordChange({\n firestore,\n orgId,\n datasetId: datasetDoc.id,\n })\n }\n /*\n * Provenance (AGL-2168). `/product/forms`'s hero mockup shows the detail\n * pane carrying `Added to \"Leads\" dataset` under the fields, so the record\n * is named on the submission — only on this path, where a record was\n * really created. A chip claiming a row that a deleted dataset or a full\n * quota refused would be worse than the silence it replaced.\n */\n return {\n routing: {\n dataset: {\n id: datasetDoc.id,\n name: datasetLabel,\n recordId: recordRef.id,\n },\n },\n }\n } catch (error) {\n console.error('form dataset append failed', error)\n return {}\n }\n}\n"],"names":["checkQuota","datasetIntegrityFields","effectiveDatasetModel","ensureDeclaredCustomFieldTypes","prepareDatasetRecordWrite","dataStorageRefusal","firebaseAdmin","orgDataCollectionForHost","announceDatasetRecordChange","resolveDatasetDoc","FieldValue","verifyFormDatasetBinding","FORM_DATASET_BINDING_FIELD","writeFormSubmissionRecord","request","hostId","orgId","fields","orgBilling","binding","body","datasetDoc","firestore","app","datasetsRef","datasetId","datasetName","exists","get","bound","model","Array","isArray","boundModel","datasetLabel","String","slice","write","fieldMap","Object","keys","errors","length","routing","datasetRefused","id","name","values","recordCount","ref","collection","count","data","allowed","doc","recordRef","add","createdAt","serverTimestamp","dataset","recordId","error","console"],"mappings":"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"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { FormDatasetBinding } from './form-dataset-binding';
|
|
18
|
+
/**
|
|
19
|
+
* The token a page carries for one form's binding on one site.
|
|
20
|
+
*
|
|
21
|
+
* Throws when `TOKEN_SIGNING_SECRET` is not configured, like every token this
|
|
22
|
+
* secret signs; a caller that renders a page decides what an unsigned form
|
|
23
|
+
* does.
|
|
24
|
+
*/
|
|
25
|
+
export declare function signFormDatasetBinding(hostId: string, binding: FormDatasetBinding): string;
|
|
26
|
+
/**
|
|
27
|
+
* The binding a token carries, or null when it is not a valid token for
|
|
28
|
+
* `hostId`.
|
|
29
|
+
*
|
|
30
|
+
* Null for anything else at all — not a string, oversized, another version, a
|
|
31
|
+
* signature that does not match, a site other than this one, or no signing
|
|
32
|
+
* secret — because the only safe reading of a binding that cannot be verified
|
|
33
|
+
* is that there is none.
|
|
34
|
+
*/
|
|
35
|
+
export declare function verifyFormDatasetBinding(hostId: string, token: unknown): FormDatasetBinding | null;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { tokenSigningSecret } from "@aglyn/tenant-data-admin/server/media-signing";
|
|
18
|
+
import { safeEqual } from "@aglyn/tenant-data-admin/server/safe-equal";
|
|
19
|
+
import { createHmac } from "crypto";
|
|
20
|
+
/**
|
|
21
|
+
* A form's dataset binding, signed into the page that renders the form.
|
|
22
|
+
*
|
|
23
|
+
* `/api/forms/submit` is public and unauthenticated, so nothing in its body
|
|
24
|
+
* may choose which dataset a record lands in: a dataset id taken from the body
|
|
25
|
+
* let anyone add rows to any dataset the site can see. The binding is instead
|
|
26
|
+
* read off the form by the compose that renders the page, signed here, and
|
|
27
|
+
* carried by the form; the route writes only where a valid signature says.
|
|
28
|
+
*
|
|
29
|
+
* What a token grants is what the published form already does. Anyone can read
|
|
30
|
+
* one out of the page, and replaying it writes to that form's dataset with
|
|
31
|
+
* that form's field map, for the site it was minted on — nothing a visitor
|
|
32
|
+
* submitting the form could not already do.
|
|
33
|
+
*
|
|
34
|
+
* Signed with the shared, fail-closed `TOKEN_SIGNING_SECRET`, the secret the
|
|
35
|
+
* other tenant tokens use, under its own `form-dataset-binding:` prefix so it
|
|
36
|
+
* can never be replayed as any other kind of token. No expiry: a published
|
|
37
|
+
* page can be served long after it rendered, and an expired token would drop
|
|
38
|
+
* a live form's records. Rotating the secret, or the version, retires every
|
|
39
|
+
* token at once.
|
|
40
|
+
*/ const VERSION = 'v1';
|
|
41
|
+
const PREFIX = 'form-dataset-binding';
|
|
42
|
+
/** Far above any real binding, so an oversized body is refused unparsed. */ const MAX_TOKEN_CHARS = 8192;
|
|
43
|
+
const signature = (payload)=>createHmac('sha256', tokenSigningSecret()).update(`${PREFIX}:${VERSION}:${payload}`).digest('hex');
|
|
44
|
+
/**
|
|
45
|
+
* The token a page carries for one form's binding on one site.
|
|
46
|
+
*
|
|
47
|
+
* Throws when `TOKEN_SIGNING_SECRET` is not configured, like every token this
|
|
48
|
+
* secret signs; a caller that renders a page decides what an unsigned form
|
|
49
|
+
* does.
|
|
50
|
+
*/ export function signFormDatasetBinding(hostId, binding) {
|
|
51
|
+
const body = _extends({
|
|
52
|
+
h: hostId
|
|
53
|
+
}, binding.datasetId ? {
|
|
54
|
+
d: binding.datasetId
|
|
55
|
+
} : {}, binding.datasetName ? {
|
|
56
|
+
n: binding.datasetName
|
|
57
|
+
} : {}, {
|
|
58
|
+
m: binding.fieldMap
|
|
59
|
+
});
|
|
60
|
+
const payload = Buffer.from(JSON.stringify(body)).toString('base64url');
|
|
61
|
+
return `${VERSION}.${payload}.${signature(payload)}`;
|
|
62
|
+
}
|
|
63
|
+
const text = (value, max)=>typeof value === 'string' ? value.trim().slice(0, max) : '';
|
|
64
|
+
/**
|
|
65
|
+
* The binding a token carries, or null when it is not a valid token for
|
|
66
|
+
* `hostId`.
|
|
67
|
+
*
|
|
68
|
+
* Null for anything else at all — not a string, oversized, another version, a
|
|
69
|
+
* signature that does not match, a site other than this one, or no signing
|
|
70
|
+
* secret — because the only safe reading of a binding that cannot be verified
|
|
71
|
+
* is that there is none.
|
|
72
|
+
*/ export function verifyFormDatasetBinding(hostId, token) {
|
|
73
|
+
try {
|
|
74
|
+
if (typeof token !== 'string' || token.length > MAX_TOKEN_CHARS) return null;
|
|
75
|
+
const [version, payload, signed, extra] = token.split('.');
|
|
76
|
+
if (version !== VERSION || !payload || !signed || extra !== undefined) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
if (!safeEqual(signed, signature(payload))) return null;
|
|
80
|
+
const body = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
81
|
+
if (!hostId || (body == null ? void 0 : body.h) !== hostId) return null;
|
|
82
|
+
const datasetId = text(body.d, 128);
|
|
83
|
+
const datasetName = text(body.n, 60);
|
|
84
|
+
if (!datasetId && !datasetName) return null;
|
|
85
|
+
const fieldMap = {};
|
|
86
|
+
if (body.m && typeof body.m === 'object' && !Array.isArray(body.m)) {
|
|
87
|
+
for (const [key, value] of Object.entries(body.m)){
|
|
88
|
+
const submittedKey = text(key, 64);
|
|
89
|
+
const datasetFieldId = text(value, 64);
|
|
90
|
+
if (submittedKey && datasetFieldId) {
|
|
91
|
+
fieldMap[submittedKey] = datasetFieldId;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return _extends({}, datasetId ? {
|
|
96
|
+
datasetId
|
|
97
|
+
} : {}, datasetName ? {
|
|
98
|
+
datasetName
|
|
99
|
+
} : {}, {
|
|
100
|
+
fieldMap
|
|
101
|
+
});
|
|
102
|
+
} catch (unused) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
//# sourceMappingURL=form-dataset-binding-token.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/data/src/lib/form-target/form-dataset-binding-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 { tokenSigningSecret } from '@aglyn/tenant-data-admin/server/media-signing'\nimport { safeEqual } from '@aglyn/tenant-data-admin/server/safe-equal'\nimport { createHmac } from 'crypto'\nimport type { FormDatasetBinding } from './form-dataset-binding'\n\n/**\n * A form's dataset binding, signed into the page that renders the form.\n *\n * `/api/forms/submit` is public and unauthenticated, so nothing in its body\n * may choose which dataset a record lands in: a dataset id taken from the body\n * let anyone add rows to any dataset the site can see. The binding is instead\n * read off the form by the compose that renders the page, signed here, and\n * carried by the form; the route writes only where a valid signature says.\n *\n * What a token grants is what the published form already does. Anyone can read\n * one out of the page, and replaying it writes to that form's dataset with\n * that form's field map, for the site it was minted on — nothing a visitor\n * submitting the form could not already do.\n *\n * Signed with the shared, fail-closed `TOKEN_SIGNING_SECRET`, the secret the\n * other tenant tokens use, under its own `form-dataset-binding:` prefix so it\n * can never be replayed as any other kind of token. No expiry: a published\n * page can be served long after it rendered, and an expired token would drop\n * a live form's records. Rotating the secret, or the version, retires every\n * token at once.\n */\n\nconst VERSION = 'v1'\nconst PREFIX = 'form-dataset-binding'\n/** Far above any real binding, so an oversized body is refused unparsed. */\nconst MAX_TOKEN_CHARS = 8192\n\ninterface SignedBinding {\n h: string\n d?: string\n n?: string\n m: Record<string, string>\n}\n\nconst signature = (payload: string): string =>\n createHmac('sha256', tokenSigningSecret())\n .update(`${PREFIX}:${VERSION}:${payload}`)\n .digest('hex')\n\n/**\n * The token a page carries for one form's binding on one site.\n *\n * Throws when `TOKEN_SIGNING_SECRET` is not configured, like every token this\n * secret signs; a caller that renders a page decides what an unsigned form\n * does.\n */\nexport function signFormDatasetBinding(\n hostId: string,\n binding: FormDatasetBinding,\n): string {\n const body: SignedBinding = {\n h: hostId,\n ...(binding.datasetId ? { d: binding.datasetId } : {}),\n ...(binding.datasetName ? { n: binding.datasetName } : {}),\n m: binding.fieldMap,\n }\n const payload = Buffer.from(JSON.stringify(body)).toString('base64url')\n return `${VERSION}.${payload}.${signature(payload)}`\n}\n\nconst text = (value: unknown, max: number): string =>\n typeof value === 'string' ? value.trim().slice(0, max) : ''\n\n/**\n * The binding a token carries, or null when it is not a valid token for\n * `hostId`.\n *\n * Null for anything else at all — not a string, oversized, another version, a\n * signature that does not match, a site other than this one, or no signing\n * secret — because the only safe reading of a binding that cannot be verified\n * is that there is none.\n */\nexport function verifyFormDatasetBinding(\n hostId: string,\n token: unknown,\n): FormDatasetBinding | null {\n try {\n if (typeof token !== 'string' || token.length > MAX_TOKEN_CHARS) return null\n const [version, payload, signed, extra] = token.split('.')\n if (version !== VERSION || !payload || !signed || extra !== undefined) {\n return null\n }\n if (!safeEqual(signed, signature(payload))) return null\n const body = JSON.parse(\n Buffer.from(payload, 'base64url').toString('utf8'),\n ) as Partial<SignedBinding>\n if (!hostId || body?.h !== hostId) return null\n const datasetId = text(body.d, 128)\n const datasetName = text(body.n, 60)\n if (!datasetId && !datasetName) return null\n const fieldMap: Record<string, string> = {}\n if (body.m && typeof body.m === 'object' && !Array.isArray(body.m)) {\n for (const [key, value] of Object.entries(body.m)) {\n const submittedKey = text(key, 64)\n const datasetFieldId = text(value, 64)\n if (submittedKey && datasetFieldId) {\n fieldMap[submittedKey] = datasetFieldId\n }\n }\n }\n return {\n ...(datasetId ? { datasetId } : {}),\n ...(datasetName ? { datasetName } : {}),\n fieldMap,\n }\n } catch {\n return null\n }\n}\n"],"names":["tokenSigningSecret","safeEqual","createHmac","VERSION","PREFIX","MAX_TOKEN_CHARS","signature","payload","update","digest","signFormDatasetBinding","hostId","binding","body","h","datasetId","d","datasetName","n","m","fieldMap","Buffer","from","JSON","stringify","toString","text","value","max","trim","slice","verifyFormDatasetBinding","token","length","version","signed","extra","split","undefined","parse","Array","isArray","key","Object","entries","submittedKey","datasetFieldId"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,kBAAkB,QAAQ,gDAA+C;AAClF,SAASC,SAAS,QAAQ,6CAA4C;AACtE,SAASC,UAAU,QAAQ,SAAQ;AAGnC;;;;;;;;;;;;;;;;;;;;CAoBC,GAED,MAAMC,UAAU;AAChB,MAAMC,SAAS;AACf,0EAA0E,GAC1E,MAAMC,kBAAkB;AASxB,MAAMC,YAAY,CAACC,UACjBL,WAAW,UAAUF,sBAClBQ,MAAM,CAAC,GAAGJ,OAAO,CAAC,EAAED,QAAQ,CAAC,EAAEI,SAAS,EACxCE,MAAM,CAAC;AAEZ;;;;;;CAMC,GACD,OAAO,SAASC,uBACdC,MAAc,EACdC,OAA2B;IAE3B,MAAMC,OAAsB;QAC1BC,GAAGH;OACCC,QAAQG,SAAS,GAAG;QAAEC,GAAGJ,QAAQG,SAAS;IAAC,IAAI,CAAC,GAChDH,QAAQK,WAAW,GAAG;QAAEC,GAAGN,QAAQK,WAAW;IAAC,IAAI,CAAC;QACxDE,GAAGP,QAAQQ,QAAQ;;IAErB,MAAMb,UAAUc,OAAOC,IAAI,CAACC,KAAKC,SAAS,CAACX,OAAOY,QAAQ,CAAC;IAC3D,OAAO,GAAGtB,QAAQ,CAAC,EAAEI,QAAQ,CAAC,EAAED,UAAUC,UAAU;AACtD;AAEA,MAAMmB,OAAO,CAACC,OAAgBC,MAC5B,OAAOD,UAAU,WAAWA,MAAME,IAAI,GAAGC,KAAK,CAAC,GAAGF,OAAO;AAE3D;;;;;;;;CAQC,GACD,OAAO,SAASG,yBACdpB,MAAc,EACdqB,KAAc;IAEd,IAAI;QACF,IAAI,OAAOA,UAAU,YAAYA,MAAMC,MAAM,GAAG5B,iBAAiB,OAAO;QACxE,MAAM,CAAC6B,SAAS3B,SAAS4B,QAAQC,MAAM,GAAGJ,MAAMK,KAAK,CAAC;QACtD,IAAIH,YAAY/B,WAAW,CAACI,WAAW,CAAC4B,UAAUC,UAAUE,WAAW;YACrE,OAAO;QACT;QACA,IAAI,CAACrC,UAAUkC,QAAQ7B,UAAUC,WAAW,OAAO;QACnD,MAAMM,OAAOU,KAAKgB,KAAK,CACrBlB,OAAOC,IAAI,CAACf,SAAS,aAAakB,QAAQ,CAAC;QAE7C,IAAI,CAACd,UAAUE,CAAAA,wBAAAA,KAAMC,CAAC,MAAKH,QAAQ,OAAO;QAC1C,MAAMI,YAAYW,KAAKb,KAAKG,CAAC,EAAE;QAC/B,MAAMC,cAAcS,KAAKb,KAAKK,CAAC,EAAE;QACjC,IAAI,CAACH,aAAa,CAACE,aAAa,OAAO;QACvC,MAAMG,WAAmC,CAAC;QAC1C,IAAIP,KAAKM,CAAC,IAAI,OAAON,KAAKM,CAAC,KAAK,YAAY,CAACqB,MAAMC,OAAO,CAAC5B,KAAKM,CAAC,GAAG;YAClE,KAAK,MAAM,CAACuB,KAAKf,MAAM,IAAIgB,OAAOC,OAAO,CAAC/B,KAAKM,CAAC,EAAG;gBACjD,MAAM0B,eAAenB,KAAKgB,KAAK;gBAC/B,MAAMI,iBAAiBpB,KAAKC,OAAO;gBACnC,IAAIkB,gBAAgBC,gBAAgB;oBAClC1B,QAAQ,CAACyB,aAAa,GAAGC;gBAC3B;YACF;QACF;QACA,OAAO,aACD/B,YAAY;YAAEA;QAAU,IAAI,CAAC,GAC7BE,cAAc;YAAEA;QAAY,IAAI,CAAC;YACrCG;;IAEJ,EAAE,eAAM;QACN,OAAO;IACT;AACF"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { AglynNodeSchema, NodeId } from '@aglyn/aglyn';
|
|
18
|
+
/**
|
|
19
|
+
* Where a form's submissions are also written as dataset records, read off
|
|
20
|
+
* the form as a page renders it.
|
|
21
|
+
*/
|
|
22
|
+
export interface FormDatasetBinding {
|
|
23
|
+
/** The dataset, by id, so renaming it never breaks the binding (AGL-556). */
|
|
24
|
+
datasetId?: string;
|
|
25
|
+
/** The legacy by-name binding, honored when no id is set (AGL-141). */
|
|
26
|
+
datasetName?: string;
|
|
27
|
+
/** Submitted field name → the dataset field id the value is stored under. */
|
|
28
|
+
fieldMap: Record<string, string>;
|
|
29
|
+
}
|
|
30
|
+
/** The most fields a submission may carry, and so the most a map can name. */
|
|
31
|
+
export declare const FORM_DATASET_BINDING_MAX_FIELDS = 20;
|
|
32
|
+
/**
|
|
33
|
+
* The dataset binding a `form` node declares, or null when it declares none.
|
|
34
|
+
*
|
|
35
|
+
* The map is built the way the browser builds the submission: every
|
|
36
|
+
* `formField` under the form, in reading order, keyed by the name the field
|
|
37
|
+
* submits under (`fieldName`, or `field` when it has none), and a later field
|
|
38
|
+
* under the same name replaces an earlier one. A field with no
|
|
39
|
+
* `datasetFieldId` maps nothing and its value falls back to matching dataset
|
|
40
|
+
* fields by name.
|
|
41
|
+
*
|
|
42
|
+
* Read from the COMPOSED tree, so a form grafted from a layout, a reusable
|
|
43
|
+
* component or a form entity is read exactly as the page renders it.
|
|
44
|
+
*/
|
|
45
|
+
export declare function formDatasetBindingOf(nodes: Record<NodeId, AglynNodeSchema | undefined> | undefined | null, formNodeId: NodeId): FormDatasetBinding | null;
|
|
46
|
+
/** Every `form` node in a composed tree that declares a dataset binding. */
|
|
47
|
+
export declare function formDatasetBindingsOf(nodes: Record<NodeId, AglynNodeSchema | undefined> | undefined | null): Array<{
|
|
48
|
+
nodeId: NodeId;
|
|
49
|
+
binding: FormDatasetBinding;
|
|
50
|
+
}>;
|