@aglyn/plugins-data 1.0.0-beta.218 → 1.0.0-beta.220

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +1 -1
  2. package/package.json +11 -9
  3. package/src/lib/constants/bundle-common.d.ts +8 -0
  4. package/src/lib/constants/bundle-common.js +6 -0
  5. package/src/lib/constants/bundle-common.js.map +1 -1
  6. package/src/lib/declarations.console-server.d.ts +47 -0
  7. package/src/lib/declarations.console-server.js +74 -0
  8. package/src/lib/declarations.console-server.js.map +1 -0
  9. package/src/lib/declarations.server.d.ts +43 -0
  10. package/src/lib/declarations.server.js +73 -0
  11. package/src/lib/declarations.server.js.map +1 -0
  12. package/src/lib/form-target/dataset-form-record-target.server.d.ts +40 -0
  13. package/src/lib/form-target/dataset-form-record-target.server.js +160 -0
  14. package/src/lib/form-target/dataset-form-record-target.server.js.map +1 -0
  15. package/src/lib/form-target/form-dataset-binding-token.d.ts +35 -0
  16. package/src/lib/form-target/form-dataset-binding-token.js +107 -0
  17. package/src/lib/form-target/form-dataset-binding-token.js.map +1 -0
  18. package/src/lib/form-target/form-dataset-binding.d.ts +50 -0
  19. package/src/lib/form-target/form-dataset-binding.js +74 -0
  20. package/src/lib/form-target/form-dataset-binding.js.map +1 -0
  21. package/src/lib/form-target/stamp-form-dataset-bindings.d.ts +39 -0
  22. package/src/lib/form-target/stamp-form-dataset-bindings.js +65 -0
  23. package/src/lib/form-target/stamp-form-dataset-bindings.js.map +1 -0
  24. package/src/lib/model/dataset-csv.d.ts +36 -0
  25. package/src/lib/model/dataset-csv.js +85 -0
  26. package/src/lib/model/dataset-csv.js.map +1 -0
  27. package/src/lib/model/dataset-io.d.ts +8 -7
  28. package/src/lib/model/dataset-io.js +9 -8
  29. package/src/lib/model/dataset-io.js.map +1 -1
  30. package/src/lib/model/dataset-record-list.d.ts +40 -0
  31. package/src/lib/model/dataset-record-list.js +61 -0
  32. package/src/lib/model/dataset-record-list.js.map +1 -0
  33. package/src/lib/model/dataset-record.d.ts +33 -0
  34. package/src/lib/model/dataset-record.js +68 -0
  35. package/src/lib/model/dataset-record.js.map +1 -0
  36. package/src/lib/plugin.js +3 -0
  37. package/src/lib/plugin.js.map +1 -1
  38. package/src/lib/repeat/dataset-repeat-rows.d.ts +2 -2
  39. package/src/lib/repeat/dataset-repeat-rows.js +2 -2
  40. package/src/lib/repeat/dataset-repeat-rows.js.map +1 -1
  41. package/src/lib/repeat/dataset-repeat-rows.server.d.ts +43 -0
  42. package/src/lib/repeat/dataset-repeat-rows.server.js +170 -0
  43. package/src/lib/repeat/dataset-repeat-rows.server.js.map +1 -0
  44. package/src/lib/repeat/dataset-repeat-source.js +3 -1
  45. package/src/lib/repeat/dataset-repeat-source.js.map +1 -1
  46. package/src/lib/server/announce-dataset-records.d.ts +37 -0
  47. package/src/lib/server/announce-dataset-records.js +54 -0
  48. package/src/lib/server/announce-dataset-records.js.map +1 -0
  49. package/src/lib/server/api-v1/datasets.d.ts +18 -0
  50. package/src/lib/server/api-v1/datasets.js +646 -0
  51. package/src/lib/server/api-v1/datasets.js.map +1 -0
  52. package/src/lib/server/api-v1/openapi.d.ts +26 -0
  53. package/src/lib/server/api-v1/openapi.js +260 -0
  54. package/src/lib/server/api-v1/openapi.js.map +1 -0
  55. package/src/lib/server/api-v1/usage.d.ts +26 -0
  56. package/src/lib/server/api-v1/usage.js +33 -0
  57. package/src/lib/server/api-v1/usage.js.map +1 -0
  58. package/src/lib/server/custom-field-types.d.ts +39 -0
  59. package/src/lib/server/custom-field-types.js +41 -0
  60. package/src/lib/server/custom-field-types.js.map +1 -0
  61. package/src/lib/server/dataset-figures.d.ts +61 -0
  62. package/src/lib/server/dataset-figures.js +413 -0
  63. package/src/lib/server/dataset-figures.js.map +1 -0
  64. package/src/lib/server/dataset-record-index.d.ts +18 -0
  65. package/src/lib/server/dataset-record-index.js +62 -0
  66. package/src/lib/server/dataset-record-index.js.map +1 -0
  67. package/src/lib/server/dataset-storage-meter.d.ts +29 -0
  68. package/src/lib/server/dataset-storage-meter.js +82 -0
  69. package/src/lib/server/dataset-storage-meter.js.map +1 -0
  70. package/src/lib/server/datasets-export-route.d.ts +57 -0
  71. package/src/lib/server/datasets-export-route.js +229 -0
  72. package/src/lib/server/datasets-export-route.js.map +1 -0
  73. package/src/lib/server/datasets-route.d.ts +38 -0
  74. package/src/lib/server/datasets-route.js +614 -0
  75. package/src/lib/server/datasets-route.js.map +1 -0
  76. package/src/lib/server.d.ts +23 -0
  77. package/src/lib/server.js +54 -0
  78. package/src/lib/server.js.map +1 -0
  79. package/src/lib/site-bundle/datasets-site-bundle.server.d.ts +87 -0
  80. package/src/lib/site-bundle/datasets-site-bundle.server.js +184 -0
  81. package/src/lib/site-bundle/datasets-site-bundle.server.js.map +1 -0
  82. package/src/lib/usage-axes.d.ts +24 -0
  83. package/src/lib/usage-axes.js +32 -0
  84. package/src/lib/usage-axes.js.map +1 -0
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/data/src/lib/server/datasets-route.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 defaultScopeForNewResource,\n newResourceScopeFields,\n pluginRequestFromWeb,\n type PluginWebApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n checkDatasetQuota,\n checkEntitlement,\n checkQuota,\n coerceDocumentValues,\n createResourceUid,\n datasetIntegrityFields,\n effectiveDatasetModel,\n ensureDeclaredCustomFieldTypes,\n memberCanSee,\n validateDocument,\n} from '@aglyn/aglyn/server'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n dataStorageRefusal,\n isImpersonationSession,\n isServerReleaseFlagOnForOrg,\n lockdownRefusal,\n memberHasOrgPermission,\n resolveOrgIdForHost,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { Timestamp } from 'firebase-admin/firestore'\nimport { announceDatasetRecords as announceDatasetChange } from './announce-dataset-records'\n\n/**\n * `dataStorageMbPerOrg` for this route, rendered as the console's 403.\n *\n * The VERDICT moved to `utils/server/data-storage-gate` (AGL-2253) — it was\n * defined here, so `/v1` and the tenant form path, which write dataset bytes\n * through their own code, enforced nothing. Only the console wording is left\n * behind, because the other two callers answer different shapes.\n */\nasync function refuseIfDataStorageBlocked(\n org: unknown,\n orgRef: FirebaseFirestore.DocumentReference,\n): Promise<Response | null> {\n const refusal = await dataStorageRefusal(org, orgRef)\n if (!refusal) return null\n return Response.json(\n {\n error:\n refusal.basis === 'always'\n ? 'Dataset storage is not included on this plan — upgrade in Billing'\n : `Dataset storage limit reached (${refusal.includedMb} MB) — ` +\n 'upgrade in Billing',\n },\n { status: 403 },\n )\n}\n\n/** Roles allowed to create org data — mirrors rules' canWriteOrgData(). */\nconst WRITER_ROLES = new Set(['owner', 'admin', 'editor'])\n\n/**\n * Rows written per import transaction.\n *\n * Firestore allows at most 500 writes in one transaction, and this is the\n * chunk size the route already used for its batches, so nothing about the\n * shape of an import changed when the batches became transactions (AGL-2371).\n */\nconst IMPORT_CHUNK = 400\n\n/**\n * Dataset/record creation API (AGL-473): creates moved out of the client\n * SDK so quotas and entitlements are enforced server-side — Firestore\n * rules deny client-side `create` on `orgs/{orgId}/datasets/**` (updates\n * and deletes stay client-direct, they don't consume quota). Actions:\n *\n * - `create-dataset`: `dataStore` entitlement + `checkDatasetQuota`\n * (addon-aware, org-scoped). An optional `hostId` names the site the\n * dataset is created from, which the org's Default sharing can narrow it\n * to.\n * - `create-record`: `recordsPerDataset` quota; values are re-coerced\n * and re-validated against the dataset's model server-side.\n * - `import-records`: batch create with the whole batch fitting the cap.\n *\n * Since AGL-2163 both record actions ALSO enforce `checkDataStorageQuota`.\n * See {@link refuseIfDataStorageBlocked}: that check's `allowed` field had no\n * reader anywhere in the platform while its docblock said free \"hard-blocks\n * at the included size\", so the byte band was documentation. `recordsPerDataset`\n * counts ROWS and does not bound their size, so it was never the same limit.\n */\nexport const datasetsHandler: PluginWebApiHandler = async (request) => {\n const { method, body, headers: rawHeaders } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'POST') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n\n const orgId = String(body?.orgId ?? '')\n const action = String(body?.action ?? '')\n if (!orgId) return Response.json({ error: 'Missing orgId' }, { status: 400 })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n const membership = await resolveOrgMembership(decoded.uid, orgId)\n const member = membership?.member as any\n if (!member || !WRITER_ROLES.has(String(member.role))) {\n return Response.json({ error: 'Editing org data requires the editor role' }, { status: 403 })\n }\n /*==========================================\n * AND the granular permission (AGL-2444).\n *\n * `data.manage` had ZERO consumers: an owner could build a custom role\n * with \"Manage data\" unticked, assign it, and the member kept creating,\n * editing and deleting datasets — because this gate read the raw role,\n * exactly the defect AGL-2350 fixed one route over on `hosts.create`.\n *\n * Identical for the built-in roles — `data.manage` is on for owner,\n * admin and editor, which is `WRITER_ROLES` — so nobody's access moves.\n * What it adds is that a custom role or a per-member override is now\n * honoured, which is the narrowing the docs sell.\n *\n * The role check is kept ALONGSIDE it rather than replaced. It mirrors\n * the rules' `canWriteOrgData()`, and a route that disagreed with the\n * rules would let one door open while the other stayed shut.\n *=========================================*/\n if (\n decoded['staff'] !== true &&\n !(await memberHasOrgPermission(orgId, member, 'data.manage'))\n ) {\n return Response.json({\n error: 'Your organization role does not allow editing organization data',\n }, { status: 403 })\n }\n\n // Release gate (AGL-1653). `<FeatureGate flag=\"release_data_store\">` on\n // the org Data page was the only gate, so org-level dataset\n // administration kept working with the flag off — the milder sibling of\n // the add-on store leak above it in the same issue. Same staff bypass as\n // `FeatureGate`'s `visible`, same 404: released-off means absent.\n if (\n decoded['staff'] !== true &&\n !(await isServerReleaseFlagOnForOrg('release_data_store', orgId))\n ) {\n return Response.json({ error: 'Not available' }, { status: 404 })\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(orgId)\n const orgSnapshot = await orgRef.get()\n if (!orgSnapshot.exists) {\n return Response.json({ error: 'Unknown organization' }, { status: 404 })\n }\n const org = orgSnapshot.data() as any\n\n // Lockdown verdict (AGL-1506): subsumes the bare `orgSuspended`\n // projection check this route used to make — the org doc is read just\n // above anyway, so the full verdict (platform/org/user scopes, staff\n // bypass, distinct 423 body) costs no extra org read here.\n const locked = await lockdownRefusal({\n request,\n staff: decoded['staff'] === true,\n uid: decoded.uid,\n org,\n })\n if (locked) return locked\n\n if (action === 'create-dataset') {\n if (!checkEntitlement(org, 'dataStore')) {\n return Response.json({\n error: 'Datasets require a Starter plan or higher',\n }, { status: 403 })\n }\n const displayName = String(body?.displayName ?? '').trim().slice(0, 120)\n const fields = Array.isArray(body?.fields)\n ? (body.fields as unknown[]).map((field) => String(field)).slice(0, 100)\n : []\n if (!displayName || fields.length === 0) {\n return Response.json({ error: 'Missing displayName or fields' }, { status: 400 })\n }\n /**\n * The site the dataset is created from — null on the organization Data\n * page, which has none.\n *\n * It reaches the document only through the org's Default sharing: with\n * `defaultResourceScope: 'host'` the new dataset is visible to this site\n * alone. That makes it a client claim deciding who can see the result,\n * so it is honored only when it holds on both counts:\n *\n * * The site is one of THIS org's. `hostIndex` is the mirror the Data\n * card resolved `orgId` from in the first place, so a create made\n * from a real site page cannot disagree with it. Another org's site\n * would stamp a token that no site and no scoped member of this org\n * holds: a dataset only the org's admins could ever find.\n * * The caller's own access reaches the scope it produces. A scoped\n * member naming a site outside their access would create a dataset\n * they cannot see afterwards, which is the create the rules'\n * `canCreateScoped()` refuses on the collections clients create.\n *\n * Both are refused rather than widened to All sites. Widening is the\n * direction that shows a dataset to sites nobody chose.\n */\n const hostId = String(body?.hostId ?? '').trim() || null\n if (\n hostId &&\n (hostId.includes('/') || (await resolveOrgIdForHost(hostId)) !== orgId)\n ) {\n return Response.json(\n { error: 'That site is not one of this organization’s' },\n { status: 400 },\n )\n }\n // Honors the org's `defaultResourceScope` (AGL-1048), falling back to\n // org-wide — which is also the only answer with no site in context.\n const visibleTo = defaultScopeForNewResource({\n defaultResourceScope: (org as {\n defaultResourceScope?: 'org' | 'host'\n })?.defaultResourceScope,\n hostId,\n })\n if (\n hostId &&\n decoded['staff'] !== true &&\n !memberCanSee(member, visibleTo)\n ) {\n return Response.json(\n { error: 'Your access to this organization does not reach that site' },\n { status: 403 },\n )\n }\n const datasetsRef = orgRef.collection('datasets')\n const overDatasetQuota = (quota: ReturnType<typeof checkDatasetQuota>) =>\n quota.upgradeRequired\n ? `Dataset limit reached (${quota.limit}) — upgrade in Billing`\n : `Dataset limit reached (${quota.limit}) — add extra datasets ` +\n `for $${quota.addonPriceUsd}/mo each or upgrade in Billing`\n // The fast refusal. NOT the enforcement point — that is the transaction\n // below — but it answers a caller who is simply over their limit before\n // the request builds anything.\n const quota = checkDatasetQuota(\n org,\n (await datasetsRef.count().get()).data().count,\n )\n if (!quota.allowed) {\n return Response.json({ error: overDatasetQuota(quota) }, { status: 403 })\n }\n const id = createResourceUid()\n // The model rides from the console (deriveModelFromFields output or\n // the join-collection template). `model` is independent of `fields`,\n // so cap its serialized size explicitly (a legit model is well under\n // this — 64 KB covers hundreds of typed fields).\n const model = body?.model && typeof body.model === 'object' ? body.model : null\n if (model && JSON.stringify(model).length > 64 * 1024) {\n return Response.json({ error: 'Dataset model too large' }, { status: 413 })\n }\n /**\n * THE ENFORCEMENT POINT (AGL-2371, the AGL-2231 treatment): the count,\n * the decision and the create in ONE transaction.\n *\n * The check above counted, then the request went on awaiting, and only\n * then created — and every await is a yield, so N concurrent creates\n * each read the same pre-count, each found room, and each landed.\n * Nothing re-counts afterwards, so the extra datasets were permanent and\n * billed nothing. `tx.get` on the aggregate takes a pessimistic lock on\n * the documents the query matched, so the loser of a race retries,\n * re-reads the higher count and is refused.\n *\n * The ORG document is deliberately read outside: the plan and the\n * purchased add-ons are the other input to this decision, but neither is\n * client-writable, so locking the org doc on every dataset create would\n * buy contention rather than correctness. That is the AGL-2369 lesson\n * applied, not skipped — there the routing map *was* client-writable and\n * *did* change the count.\n *\n * The refusal comes back as data and is rendered outside: building a\n * response inside a body that can run several times reads as if the\n * transaction were a place effects happen.\n */\n const refusal = await firestore.runTransaction<string | null>(\n async (tx) => {\n const live = (await tx.get(datasetsRef.count())).data().count\n const authoritative = checkDatasetQuota(org, live)\n if (!authoritative.allowed) return overDatasetQuota(authoritative)\n tx.create(datasetsRef.doc(id), {\n displayName,\n fields,\n ...(model ? { model } : {}),\n // The scope decided above. Stamping SOMETHING is mandatory: an\n // `array-contains-any` matches nothing on a doc missing the field,\n // so an unstamped dataset renders on no site at all (AGL-1044).\n //\n // Through `newResourceScopeFields` since AGL-1484: AGL-1478 built\n // that helper as the type-level gate for exactly the collections\n // with no document constructor of their own — `datasets` named\n // first among them — and then only the marketplace fork used it,\n // while the two ordinary dataset creates went on writing the field\n // by hand. A required argument that three of four callers bypass\n // is a convention, not a guarantee.\n ...newResourceScopeFields(visibleTo),\n createdAt: Timestamp.now(),\n })\n return null\n },\n )\n if (refusal) return Response.json({ error: refusal }, { status: 403 })\n return Response.json({ ok: true, id }, { status: 200 })\n }\n\n if (\n action === 'create-record' ||\n action === 'import-records' ||\n action === 'announce-records'\n ) {\n const datasetId = String(body?.datasetId ?? '')\n if (!datasetId) {\n return Response.json({ error: 'Missing datasetId' }, { status: 400 })\n }\n const datasetRef = orgRef.collection('datasets').doc(datasetId)\n const datasetSnapshot = await datasetRef.get()\n if (!datasetSnapshot.exists) {\n return Response.json({ error: 'Unknown dataset' }, { status: 404 })\n }\n /**\n * The caller has to be able to SEE the dataset they add rows to.\n *\n * The role and `data.manage` gates above are about the org, and a member\n * scoped to one site passes both. The rules hold every record update and\n * delete to the parent dataset being visible (`parentDatasetVisible()`),\n * and refuse client record creates outright, so this route is the one\n * place a member's create can be held to the same condition. Without it,\n * a collaborator on one client's site could add rows, by id alone, to an\n * internal dataset shared only with other sites.\n *\n * The Admin SDK evaluates no rules, so `memberCanSee` IS the enforcement\n * here, as on the export route beside this one. Answered with the same\n * 404 as a dataset that does not exist: whether one outside the caller's\n * reach exists is not theirs to learn either.\n */\n if (\n decoded['staff'] !== true &&\n !memberCanSee(\n member,\n (datasetSnapshot.data() as { visibleTo?: string[] } | undefined)\n ?.visibleTo,\n )\n ) {\n return Response.json({ error: 'Unknown dataset' }, { status: 404 })\n }\n /**\n * A record the BROWSER wrote, announcing itself (AGL-3113).\n *\n * Record edits and deletes stay client-direct on purpose — they consume\n * no quota, so AGL-473 left them out of the server leg — which also left\n * them with nothing to announce from. They are the same staleness as a\n * create: a page listing the rows goes on showing the old one.\n *\n * A drop, and only a drop. It moves no pointer, writes nothing and\n * registers no route, so it grants nothing the rules withhold — and it\n * is gated by the same org membership and the same `memberCanSee` the\n * create is, which is what stops a collaborator on one site from\n * learning that another site's dataset exists.\n */\n if (action === 'announce-records') {\n const announced = await announceDatasetChange({\n firestore,\n orgId,\n datasetId,\n })\n return Response.json({ ok: true, announced }, { status: 200 })\n }\n const model = effectiveDatasetModel(datasetSnapshot.data() as any)\n // Before either validation below: a plugin's field validator only runs\n // once its plugin's server entry has registered it.\n await ensureDeclaredCustomFieldTypes(model)\n const recordsRef = datasetRef.collection('records')\n const recordCount = (await recordsRef.count().get()).data().count\n const overRecordQuota = (limit: number) =>\n `Record limit reached (${limit}) — upgrade in Billing`\n\n if (action === 'create-record') {\n const coerced = coerceDocumentValues(model, body?.values ?? {})\n const errors = validateDocument(model, coerced)\n if (Object.keys(errors).length) {\n return Response.json({ error: 'Record failed validation', errors }, { status: 400 })\n }\n // The fast refusal. NOT the enforcement point — that is the\n // transaction below — but it keeps the refusal ORDER on this route\n // intact: a caller over BOTH the row cap and the byte band is told\n // about the rows, as they always were.\n const quota = checkQuota(org, 'recordsPerDataset', recordCount)\n if (!quota.allowed) {\n return Response.json({\n error: overRecordQuota(quota.limit),\n }, { status: 403 })\n }\n // Bytes, not rows (AGL-2163). Before the write, like every other\n // quota on this route.\n const storageRefusal = await refuseIfDataStorageBlocked(org, orgRef)\n if (storageRefusal) return storageRefusal\n const id = createResourceUid()\n /**\n * THE ENFORCEMENT POINT (AGL-2371, the AGL-2231 treatment): the count,\n * the decision and the create in ONE transaction.\n *\n * The check above counted, then `refuseIfDataStorageBlocked` awaited,\n * and only then did the row land. Every await is a yield, so N\n * concurrent creates each read the same pre-count, each found room,\n * and each landed — permanently, since nothing re-counts afterwards. A\n * free-with-rows-granted org's thousand rows became twenty thousand by\n * sending twenty requests at once.\n *\n * `tx.get` on the aggregate takes a pessimistic lock on the documents\n * the query matched, so the loser retries, re-reads the higher count\n * and is refused. `order` is taken from the transaction's own count\n * for the same reason: the pre-count is what made two concurrent rows\n * share an order.\n *\n * The BYTE gate stays outside. It is a different quota with its own\n * reads, it is not a function of the rows this transaction writes, and\n * re-running it on every retry attempt would put a multi-document read\n * inside a lock.\n */\n const refusal = await firestore.runTransaction<string | null>(\n async (tx) => {\n const live = (await tx.get(recordsRef.count())).data().count\n const authoritative = checkQuota(org, 'recordsPerDataset', live)\n if (!authoritative.allowed) return overRecordQuota(authoritative.limit)\n tx.create(recordsRef.doc(id), {\n values: coerced,\n // The integrity index the delete check queries. Written on the\n // same write as the values it describes — an index that lags\n // them reports a reference the record no longer holds, or\n // misses one it does.\n ...datasetIntegrityFields(model, coerced),\n order: live,\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n })\n return null\n },\n )\n if (refusal) return Response.json({ error: refusal }, { status: 403 })\n // The record is stored; now the pages that repeat over it are told\n // (AGL-3113). Awaited so the site is already serving the new row by the\n // time the editor's save returns, and reported so a refused drop can\n // say the live pages are behind rather than leaving it to be noticed.\n const announced = await announceDatasetChange({\n firestore,\n orgId,\n datasetId,\n })\n return Response.json({ ok: true, id, announced }, { status: 200 })\n }\n\n // import-records: the console sends only the NEW rows (updates to\n // existing ids stay client-direct — they don't consume quota).\n const rows = Array.isArray(body?.records)\n ? (body.records as Array<{ values?: unknown }>)\n : []\n if (rows.length === 0) {\n return Response.json({ error: 'No records to import' }, { status: 400 })\n }\n const overImportQuota = (limit: number, needed: number) =>\n `This import needs ${needed} record slots — your plan ` +\n `allows ${limit} per dataset. See Billing to upgrade.`\n // The fast refusal, on the whole import. NOT the enforcement point —\n // that is the per-chunk transaction below — but it is what answers an\n // import that never fitted, before a single row is written.\n const quota = checkQuota(\n org,\n 'recordsPerDataset',\n recordCount + rows.length - 1,\n )\n if (!quota.allowed) {\n return Response.json({\n error: overImportQuota(quota.limit, rows.length),\n }, { status: 403 })\n }\n // Bytes, not rows (AGL-2163). An import is the path that moves the\n // most bytes in one request, so leaving it out would have left the\n // enforcement exactly where the customer does not meet it.\n const importStorageRefusal = await refuseIfDataStorageBlocked(org, orgRef)\n if (importStorageRefusal) return importStorageRefusal\n const prepared = rows.map((row, index) => {\n const coerced = coerceDocumentValues(\n model,\n (row?.values ?? {}) as Record<string, unknown>,\n )\n const errors = validateDocument(model, coerced)\n return { index, coerced, valid: Object.keys(errors).length === 0 }\n })\n const invalid = prepared.filter((row) => !row.valid)\n if (invalid.length) {\n return Response.json({\n error: `${invalid.length} rows failed validation`,\n rows: invalid.map((row) => row.index),\n }, { status: 400 })\n }\n /**\n * THE ENFORCEMENT POINT (AGL-2371): each chunk counts, decides and\n * writes inside ONE transaction.\n *\n * The import used to count once, decide once, and then commit a series\n * of `WriteBatch`es. A batch is atomic but NOT conditional on a read\n * taken before it, so two concurrent imports each priced themselves off\n * the same pre-count and both landed in full — the create leg's defect,\n * multiplied by the batch size.\n *\n * It cannot be ONE transaction for every import the way the single-row\n * create can: `recordsPerDataset` runs to a million, and a transaction\n * carries at most 500 writes. So the chunk is the unit, and each chunk's\n * count is re-read under the lock and re-checked against the plan. The\n * cap therefore holds ABSOLUTELY at every size — what a race can cost is\n * a partial import, not an overshoot, and a partial import was already\n * possible the moment a mid-import batch could fail.\n *\n * The ids are minted before the transaction so a retry re-creates the\n * SAME documents rather than a second copy of the chunk.\n *\n * This is not AGL-2370's problem in miniature. There the whole bundle\n * must land or none of it, so chunking cannot be the unit and a lease is\n * needed; here rows are independent and \"fewer rows than asked for\" is a\n * reportable outcome.\n */\n const ids: string[] = []\n let refusedAt: string | null = null\n for (\n let start = 0;\n start < prepared.length && refusedAt === null;\n start += IMPORT_CHUNK\n ) {\n const chunk = prepared.slice(start, start + IMPORT_CHUNK)\n const chunkIds = chunk.map(() => createResourceUid())\n refusedAt = await firestore.runTransaction<string | null>(async (tx) => {\n const live = (await tx.get(recordsRef.count())).data().count\n const authoritative = checkQuota(\n org,\n 'recordsPerDataset',\n live + chunk.length - 1,\n )\n if (!authoritative.allowed) {\n return overImportQuota(authoritative.limit, rows.length)\n }\n chunk.forEach((row, offset) => {\n tx.create(recordsRef.doc(chunkIds[offset]), {\n values: row.coerced,\n ...datasetIntegrityFields(model, row.coerced),\n order: live + offset,\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n })\n })\n return null\n })\n if (refusedAt === null) ids.push(...chunkIds)\n }\n /**\n * ONE announce for the whole import, after the loop (AGL-3113).\n *\n * Not per chunk and certainly not per row: a thousand rows arrive as one\n * import and make the same pages stale once, so the pages are worked out\n * once and dropped once. The refusal path announces too — rows that\n * landed before the quota bit are live records, and leaving them out of\n * the refresh would make a partial import look like no import at all.\n */\n if (ids.length) {\n await announceDatasetChange({ firestore, orgId, datasetId })\n }\n if (refusedAt !== null) {\n // What landed is reported alongside the refusal. Rows are independent,\n // so silently discarding the created ids would tell the caller nothing\n // was written while the dataset says otherwise.\n return Response.json(\n { error: refusedAt, ids, created: ids.length },\n { status: 403 },\n )\n }\n return Response.json({ ok: true, ids, created: ids.length }, { status: 200 })\n }\n\n return Response.json({ error: 'Unknown action' }, { status: 400 })\n } catch (error) {\n // A refused credential is a 401, not a fault of ours (AGL-1993); anything\n // else keeps the answer below.\n if (isRefusedIdToken(error)) {\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n }\n console.error(error)\n return Response.json({ error: 'Dataset operation failed' }, { status: 500 })\n }\n}\n"],"names":["defaultScopeForNewResource","newResourceScopeFields","pluginRequestFromWeb","checkDatasetQuota","checkEntitlement","checkQuota","coerceDocumentValues","createResourceUid","datasetIntegrityFields","effectiveDatasetModel","ensureDeclaredCustomFieldTypes","memberCanSee","validateDocument","emailUnverifiedResponse","firebaseAdmin","dataStorageRefusal","isImpersonationSession","isServerReleaseFlagOnForOrg","lockdownRefusal","memberHasOrgPermission","resolveOrgIdForHost","resolveOrgMembership","isRefusedIdToken","Timestamp","announceDatasetRecords","announceDatasetChange","refuseIfDataStorageBlocked","org","orgRef","refusal","Response","json","error","basis","includedMb","status","WRITER_ROLES","Set","IMPORT_CHUNK","datasetsHandler","request","headers","method","body","rawHeaders","authorization","idToken","startsWith","slice","length","undefined","orgId","String","action","decoded","app","auth","verifyIdToken","email_verified","membership","uid","member","has","role","firestore","collection","doc","orgSnapshot","get","exists","data","locked","staff","displayName","trim","fields","Array","isArray","map","field","hostId","includes","visibleTo","defaultResourceScope","datasetsRef","overDatasetQuota","quota","upgradeRequired","limit","addonPriceUsd","count","allowed","id","model","JSON","stringify","runTransaction","tx","live","authoritative","create","createdAt","now","ok","datasetSnapshot","datasetId","datasetRef","announced","recordsRef","recordCount","overRecordQuota","coerced","values","errors","Object","keys","storageRefusal","order","updatedAt","rows","records","overImportQuota","needed","importStorageRefusal","prepared","row","index","valid","invalid","filter","ids","refusedAt","start","chunk","chunkIds","forEach","offset","push","created","console"],"mappings":"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"}
@@ -0,0 +1,23 @@
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
+ * Console API: the organization's datasets — creating a dataset and its
19
+ * records within the plan (`POST /api/orgs/datasets`), and the complete export
20
+ * of one (`GET /api/orgs/datasets/export`). Served by the console's plugin
21
+ * dispatcher, at the addresses the console always answered them on.
22
+ */
23
+ export declare function registerDataConsoleApi(): void;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { registerPluginApiRoute } from "@aglyn/aglyn/server";
17
+ import { datasetsExportHandler } from "./server/datasets-export-route.js";
18
+ import { datasetsHandler } from "./server/datasets-route.js";
19
+ /**
20
+ * The organization a datasets request is for, read from the request the way
21
+ * each handler reads it — the JSON body's `orgId` on a write, `?orgId=` on an
22
+ * export. Datasets belong to the organization and name no site, so without
23
+ * this the dispatcher's release gate would read the request as anonymous and
24
+ * refuse it under a partial rollout of the data store.
25
+ */ const orgSubject = async (request)=>{
26
+ const fromQuery = new URL(request.url).searchParams.get('orgId');
27
+ if (fromQuery) return {
28
+ orgId: fromQuery
29
+ };
30
+ if (request.method === 'GET' || request.method === 'HEAD') return null;
31
+ const body = await request.json().catch(()=>null);
32
+ return typeof (body == null ? void 0 : body.orgId) === 'string' && body.orgId ? {
33
+ orgId: body.orgId
34
+ } : null;
35
+ };
36
+ /**
37
+ * Console API: the organization's datasets — creating a dataset and its
38
+ * records within the plan (`POST /api/orgs/datasets`), and the complete export
39
+ * of one (`GET /api/orgs/datasets/export`). Served by the console's plugin
40
+ * dispatcher, at the addresses the console always answered them on.
41
+ */ export function registerDataConsoleApi() {
42
+ registerPluginApiRoute('orgs/datasets', {
43
+ web: datasetsHandler
44
+ }, {
45
+ subject: orgSubject
46
+ });
47
+ registerPluginApiRoute('orgs/datasets/export', {
48
+ web: datasetsExportHandler
49
+ }, {
50
+ subject: orgSubject
51
+ });
52
+ }
53
+
54
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/plugins/data/src/lib/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 registerPluginApiRoute,\n type PluginApiSubjectResolver,\n} from '@aglyn/aglyn/server'\nimport { datasetsExportHandler } from './server/datasets-export-route'\nimport { datasetsHandler } from './server/datasets-route'\n\n/**\n * The organization a datasets request is for, read from the request the way\n * each handler reads it — the JSON body's `orgId` on a write, `?orgId=` on an\n * export. Datasets belong to the organization and name no site, so without\n * this the dispatcher's release gate would read the request as anonymous and\n * refuse it under a partial rollout of the data store.\n */\nconst orgSubject: PluginApiSubjectResolver = async (request) => {\n const fromQuery = new URL(request.url).searchParams.get('orgId')\n if (fromQuery) return { orgId: fromQuery }\n if (request.method === 'GET' || request.method === 'HEAD') return null\n const body = (await request.json().catch(() => null)) as {\n orgId?: unknown\n } | null\n return typeof body?.orgId === 'string' && body.orgId\n ? { orgId: body.orgId }\n : null\n}\n\n/**\n * Console API: the organization's datasets — creating a dataset and its\n * records within the plan (`POST /api/orgs/datasets`), and the complete export\n * of one (`GET /api/orgs/datasets/export`). Served by the console's plugin\n * dispatcher, at the addresses the console always answered them on.\n */\nexport function registerDataConsoleApi(): void {\n registerPluginApiRoute('orgs/datasets', { web: datasetsHandler }, {\n subject: orgSubject,\n })\n registerPluginApiRoute(\n 'orgs/datasets/export',\n { web: datasetsExportHandler },\n { subject: orgSubject },\n )\n}\n"],"names":["registerPluginApiRoute","datasetsExportHandler","datasetsHandler","orgSubject","request","fromQuery","URL","url","searchParams","get","orgId","method","body","json","catch","registerDataConsoleApi","web","subject"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,sBAAsB,QAEjB,sBAAqB;AAC5B,SAASC,qBAAqB,QAAQ,oCAAgC;AACtE,SAASC,eAAe,QAAQ,6BAAyB;AAEzD;;;;;;CAMC,GACD,MAAMC,aAAuC,OAAOC;IAClD,MAAMC,YAAY,IAAIC,IAAIF,QAAQG,GAAG,EAAEC,YAAY,CAACC,GAAG,CAAC;IACxD,IAAIJ,WAAW,OAAO;QAAEK,OAAOL;IAAU;IACzC,IAAID,QAAQO,MAAM,KAAK,SAASP,QAAQO,MAAM,KAAK,QAAQ,OAAO;IAClE,MAAMC,OAAQ,MAAMR,QAAQS,IAAI,GAAGC,KAAK,CAAC,IAAM;IAG/C,OAAO,QAAOF,wBAAAA,KAAMF,KAAK,MAAK,YAAYE,KAAKF,KAAK,GAChD;QAAEA,OAAOE,KAAKF,KAAK;IAAC,IACpB;AACN;AAEA;;;;;CAKC,GACD,OAAO,SAASK;IACdf,uBAAuB,iBAAiB;QAAEgB,KAAKd;IAAgB,GAAG;QAChEe,SAASd;IACX;IACAH,uBACE,wBACA;QAAEgB,KAAKf;IAAsB,GAC7B;QAAEgB,SAASd;IAAW;AAE1B"}
@@ -0,0 +1,87 @@
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 { SiteBundleImportRequest, SiteBundleItem, SiteBundleReportRow, SiteBundleRequest, SiteBundleRestoreRequest } from '@aglyn/aglyn/plugin-manager/plugin-site-bundle';
18
+ /**
19
+ * A site's datasets in its whole-site backup (AGL-163), answered by the plugin
20
+ * that keeps them (AGL-3080).
21
+ *
22
+ * Datasets are ORG-owned (AGL-237) and shared with sites by scope, so a site's
23
+ * backup carries the datasets that site may see (AGL-1046) — each with its
24
+ * records — and a restore writes them back into the owning organization,
25
+ * scoped to the site it restores into.
26
+ */
27
+ /** The records one dataset carries in a bundle, and the most a restore writes back. */
28
+ export declare const DATASET_BUNDLE_RECORD_LIMIT = 1000;
29
+ /**
30
+ * The keys a restore writes back on a dataset (AGL-1382): every key a live
31
+ * dataset carries, because the restore writes with `merge: false` and a key
32
+ * left off is ERASED. `visibleTo` is never on it — the restore assigns a fresh
33
+ * scope for the site it restores into, since a bundle is portable and an
34
+ * embedded `['org']` would publish one organization's data across another's
35
+ * whole client roster.
36
+ */
37
+ export declare const DATASET_RESTORE_FIELDS: readonly string[];
38
+ /**
39
+ * The keys a restore writes back on a record. `order` is what
40
+ * `sortDatasetRecords` sorts by, so dropping it reorders every repeatable on
41
+ * the restored site. The integrity index is DERIVED on the way in, never
42
+ * carried (see {@link importSiteDatasets}).
43
+ */
44
+ export declare const RECORD_RESTORE_FIELDS: readonly string[];
45
+ /**
46
+ * The site's share: the datasets the site may see, each with its records.
47
+ *
48
+ * A site with no organization genuinely has no datasets — a known-empty
49
+ * answer. Everything else THROWS: a catch-all would ship a silently empty,
50
+ * still-successful "backup" that nobody discovers until they restore it. An
51
+ * export that fails loudly is recoverable; one that lies is not.
52
+ *
53
+ * `visibleTo` is stripped on the way out. Its `host:` tokens name sites of
54
+ * THIS organization, so they are noise at best and a dangling reference once
55
+ * the bundle is restored elsewhere; the restore assigns a fresh scope.
56
+ */
57
+ export declare function exportSiteDatasets({ hostId, orgId, limit, }: SiteBundleRequest): Promise<SiteBundleItem[]>;
58
+ /**
59
+ * Refuse a bundle that would put the workspace over the datasets it may hold
60
+ * (AGL-1403) — decided before the restore writes anything.
61
+ *
62
+ * Datasets are SOLD: the plan includes some and the workspace buys more as
63
+ * add-ons, so `checkDatasetQuota` decides, never the plan's included number —
64
+ * a workspace that paid for extra datasets is entitled to them, and refusing
65
+ * its own backup after taking its money for the room would be wrong.
66
+ *
67
+ * A restore is identified by id COLLISION, never by the bundle's own claim of
68
+ * where it came from (an unsigned string in a file the metered party
69
+ * uploads). The meter is per organization, so a collision here means the
70
+ * WORKSPACE already holds the dataset: restoring into a sibling site of the
71
+ * same workspace rewrites datasets it already pays for and raises nothing,
72
+ * while a copy into a different workspace collides on nothing and is refused
73
+ * when it lands that workspace over. What is refused is the RAISE, never the
74
+ * state of being over — a workspace already over its limit may still restore
75
+ * its own backup.
76
+ */
77
+ export declare function siteDatasetsRefusal({ orgId, org, items, }: SiteBundleRestoreRequest): Promise<string | null>;
78
+ /**
79
+ * Writes the bundle's datasets into the owning organization, scoped to the
80
+ * site restored into (AGL-237, AGL-1046), each with its records.
81
+ *
82
+ * Non-conforming records are restored AND reported (AGL-182): data is never
83
+ * silently dropped, and the report tells the owner what to fix. A bundle from
84
+ * before typed models validates through the derived text model, as the live
85
+ * migration does, so everything in it passes.
86
+ */
87
+ export declare function importSiteDatasets(request: SiteBundleImportRequest): Promise<SiteBundleReportRow[]>;
@@ -0,0 +1,184 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
3
+ /**
4
+ * @license
5
+ * Copyright 2026 Aglyn LLC
6
+ *
7
+ * Licensed under the Apache License, Version 2.0 (the "License");
8
+ * you may not use this file except in compliance with the License.
9
+ * You may obtain a copy of the License at
10
+ *
11
+ * http://www.apache.org/licenses/LICENSE-2.0
12
+ *
13
+ * Unless required by applicable law or agreed to in writing, software
14
+ * distributed under the License is distributed on an "AS IS" BASIS,
15
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ * See the License for the specific language governing permissions and
17
+ * limitations under the License.
18
+ */ import { datasetIntegrityFields, effectiveDatasetModel, validateDocument } from "@aglyn/aglyn/app-utils/dataset-models";
19
+ import { checkDatasetQuota } from "@aglyn/aglyn/app-utils/plan-entitlements";
20
+ import { hostScopeToken, newResourceScopeFields } from "@aglyn/aglyn/app-utils/scope-tokens";
21
+ import { firebaseAdmin, scopedToHost } from "@aglyn/tenant-data-admin";
22
+ import { loadCustomFieldTypes } from "../server/custom-field-types.js";
23
+ /**
24
+ * A site's datasets in its whole-site backup (AGL-163), answered by the plugin
25
+ * that keeps them (AGL-3080).
26
+ *
27
+ * Datasets are ORG-owned (AGL-237) and shared with sites by scope, so a site's
28
+ * backup carries the datasets that site may see (AGL-1046) — each with its
29
+ * records — and a restore writes them back into the owning organization,
30
+ * scoped to the site it restores into.
31
+ */ /** The records one dataset carries in a bundle, and the most a restore writes back. */ export const DATASET_BUNDLE_RECORD_LIMIT = 1000;
32
+ /**
33
+ * The keys a restore writes back on a dataset (AGL-1382): every key a live
34
+ * dataset carries, because the restore writes with `merge: false` and a key
35
+ * left off is ERASED. `visibleTo` is never on it — the restore assigns a fresh
36
+ * scope for the site it restores into, since a bundle is portable and an
37
+ * embedded `['org']` would publish one organization's data across another's
38
+ * whole client roster.
39
+ */ export const DATASET_RESTORE_FIELDS = [
40
+ 'displayName',
41
+ // Pre-AGL-536 human name, still read as a fallback by site search.
42
+ 'name',
43
+ 'fields',
44
+ // The typed DatasetModel, and exactly what `effectiveDatasetModel` reads
45
+ // below. Drop it and every restored dataset silently degrades to the
46
+ // derived all-text v1 model, losing types, `required`, `validation`,
47
+ // `default` and every `reference` link (AGL-180).
48
+ 'model',
49
+ 'names',
50
+ 'description',
51
+ 'source',
52
+ 'installedFrom',
53
+ 'detachedFrom'
54
+ ];
55
+ /**
56
+ * The keys a restore writes back on a record. `order` is what
57
+ * `sortDatasetRecords` sorts by, so dropping it reorders every repeatable on
58
+ * the restored site. The integrity index is DERIVED on the way in, never
59
+ * carried (see {@link importSiteDatasets}).
60
+ */ export const RECORD_RESTORE_FIELDS = [
61
+ 'values',
62
+ 'order'
63
+ ];
64
+ /** One of a bundle's documents, reduced to the keys a restore may write. */ function restorable(fields, input) {
65
+ const allowed = new Set(fields);
66
+ const out = {};
67
+ for (const [key, value] of Object.entries(input != null ? input : {})){
68
+ // Only `undefined` is absence — Firestore rejects it outright.
69
+ if (allowed.has(key) && value !== undefined) out[key] = value;
70
+ }
71
+ return out;
72
+ }
73
+ /** The ids a bundle would write — distinct, since two items sharing one are one document. */ function bundleIds(items) {
74
+ const ids = new Set();
75
+ for (const item of items)if (item == null ? void 0 : item.$id) ids.add(String(item.$id));
76
+ return ids;
77
+ }
78
+ const datasetsOf = (orgId)=>firebaseAdmin.app().firestore().collection('orgs').doc(orgId).collection('datasets');
79
+ /**
80
+ * The site's share: the datasets the site may see, each with its records.
81
+ *
82
+ * A site with no organization genuinely has no datasets — a known-empty
83
+ * answer. Everything else THROWS: a catch-all would ship a silently empty,
84
+ * still-successful "backup" that nobody discovers until they restore it. An
85
+ * export that fails loudly is recoverable; one that lies is not.
86
+ *
87
+ * `visibleTo` is stripped on the way out. Its `host:` tokens name sites of
88
+ * THIS organization, so they are noise at best and a dangling reference once
89
+ * the bundle is restored elsewhere; the restore assigns a fresh scope.
90
+ */ export async function exportSiteDatasets({ hostId, orgId, limit }) {
91
+ if (!orgId) return [];
92
+ const snapshot = await scopedToHost(datasetsOf(orgId), hostId).limit(limit).get();
93
+ const live = snapshot.docs.filter((doc)=>!doc.get('deletedAt'));
94
+ return Promise.all(live.map(async (doc)=>{
95
+ const _doc_data = doc.data(), { visibleTo: _visibleTo } = _doc_data, data = _object_without_properties_loose(_doc_data, [
96
+ "visibleTo"
97
+ ]);
98
+ const records = await doc.ref.collection('records').limit(DATASET_BUNDLE_RECORD_LIMIT).get();
99
+ return _extends({
100
+ $id: doc.id
101
+ }, data, {
102
+ records: records.docs.map((record)=>_extends({
103
+ $id: record.id
104
+ }, record.data()))
105
+ });
106
+ }));
107
+ }
108
+ /**
109
+ * Refuse a bundle that would put the workspace over the datasets it may hold
110
+ * (AGL-1403) — decided before the restore writes anything.
111
+ *
112
+ * Datasets are SOLD: the plan includes some and the workspace buys more as
113
+ * add-ons, so `checkDatasetQuota` decides, never the plan's included number —
114
+ * a workspace that paid for extra datasets is entitled to them, and refusing
115
+ * its own backup after taking its money for the room would be wrong.
116
+ *
117
+ * A restore is identified by id COLLISION, never by the bundle's own claim of
118
+ * where it came from (an unsigned string in a file the metered party
119
+ * uploads). The meter is per organization, so a collision here means the
120
+ * WORKSPACE already holds the dataset: restoring into a sibling site of the
121
+ * same workspace rewrites datasets it already pays for and raises nothing,
122
+ * while a copy into a different workspace collides on nothing and is refused
123
+ * when it lands that workspace over. What is refused is the RAISE, never the
124
+ * state of being over — a workspace already over its limit may still restore
125
+ * its own backup.
126
+ */ export async function siteDatasetsRefusal({ orgId, org, items }) {
127
+ const bundled = bundleIds(items);
128
+ if (!orgId || !bundled.size) return null;
129
+ if (!Number.isFinite(checkDatasetQuota(org, 0).limit)) return null;
130
+ // A field mask with no fields projects to the document id alone.
131
+ const held = await datasetsOf(orgId).select().get();
132
+ const existing = new Set(held.docs.map((doc)=>doc.id));
133
+ const next = new Set([
134
+ ...existing,
135
+ ...bundled
136
+ ]).size;
137
+ // `allowed` asks "may I add one more to N", so the post state N is checked
138
+ // as `N - 1` — the idiom the dataset routes' own bulk paths use.
139
+ const quota = checkDatasetQuota(org, next - 1);
140
+ if (next <= existing.size || quota.allowed) return null;
141
+ return `This backup holds ${bundled.size} datasets and this workspace ` + `has ${existing.size}, which would put it at ${next} of ${quota.limit} ` + 'datasets. Nothing was imported — ' + (quota.upgradeRequired ? 'upgrade in Billing.' : `add extra datasets for $${quota.addonPriceUsd}/mo each, or ` + 'upgrade in Billing.');
142
+ }
143
+ /**
144
+ * Writes the bundle's datasets into the owning organization, scoped to the
145
+ * site restored into (AGL-237, AGL-1046), each with its records.
146
+ *
147
+ * Non-conforming records are restored AND reported (AGL-182): data is never
148
+ * silently dropped, and the report tells the owner what to fix. A bundle from
149
+ * before typed models validates through the derived text model, as the live
150
+ * migration does, so everything in it passes.
151
+ */ export async function importSiteDatasets(request) {
152
+ const { hostId, orgId, items, write, stamps } = request;
153
+ if (!orgId) return [];
154
+ // Through the scoped-create gate (AGL-1478, AGL-1484): a restore CREATES
155
+ // datasets, and a dataset is born scoped to the site it lands in.
156
+ const scope = newResourceScopeFields([
157
+ hostScopeToken(hostId)
158
+ ]);
159
+ const report = [];
160
+ for (const item of items){
161
+ if (!(item == null ? void 0 : item.$id)) continue;
162
+ const path = `orgs/${orgId}/datasets/${String(item.$id)}`;
163
+ await write(path, _extends({}, restorable(DATASET_RESTORE_FIELDS, item), stamps(), scope));
164
+ const model = effectiveDatasetModel(item);
165
+ await loadCustomFieldTypes(model, request.loadPluginSurfaces);
166
+ const records = Array.isArray(item['records']) ? item['records'] : [];
167
+ for (const record of records.slice(0, DATASET_BUNDLE_RECORD_LIMIT)){
168
+ var _record_values, _record_values1;
169
+ if (!(record == null ? void 0 : record.$id)) continue;
170
+ const errors = validateDocument(model, (_record_values = record['values']) != null ? _record_values : {});
171
+ if (Object.keys(errors).length) {
172
+ report.push({
173
+ datasetId: String(item.$id),
174
+ recordId: String(record.$id),
175
+ errors
176
+ });
177
+ }
178
+ await write(`${path}/records/${String(record.$id)}`, _extends({}, restorable(RECORD_RESTORE_FIELDS, record), stamps(), datasetIntegrityFields(model, (_record_values1 = record['values']) != null ? _record_values1 : {})));
179
+ }
180
+ }
181
+ return report;
182
+ }
183
+
184
+ //# sourceMappingURL=datasets-site-bundle.server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/data/src/lib/site-bundle/datasets-site-bundle.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 datasetIntegrityFields,\n effectiveDatasetModel,\n validateDocument,\n} from '@aglyn/aglyn/app-utils/dataset-models'\nimport { checkDatasetQuota } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { hostScopeToken, newResourceScopeFields } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport type {\n SiteBundleImportRequest,\n SiteBundleItem,\n SiteBundleReportRow,\n SiteBundleRequest,\n SiteBundleRestoreRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-site-bundle'\nimport { firebaseAdmin, scopedToHost } from '@aglyn/tenant-data-admin'\nimport { loadCustomFieldTypes } from '../server/custom-field-types'\n\n/**\n * A site's datasets in its whole-site backup (AGL-163), answered by the plugin\n * that keeps them (AGL-3080).\n *\n * Datasets are ORG-owned (AGL-237) and shared with sites by scope, so a site's\n * backup carries the datasets that site may see (AGL-1046) — each with its\n * records — and a restore writes them back into the owning organization,\n * scoped to the site it restores into.\n */\n\n/** The records one dataset carries in a bundle, and the most a restore writes back. */\nexport const DATASET_BUNDLE_RECORD_LIMIT = 1000\n\n/**\n * The keys a restore writes back on a dataset (AGL-1382): every key a live\n * dataset carries, because the restore writes with `merge: false` and a key\n * left off is ERASED. `visibleTo` is never on it — the restore assigns a fresh\n * scope for the site it restores into, since a bundle is portable and an\n * embedded `['org']` would publish one organization's data across another's\n * whole client roster.\n */\nexport const DATASET_RESTORE_FIELDS: readonly string[] = [\n 'displayName',\n // Pre-AGL-536 human name, still read as a fallback by site search.\n 'name',\n 'fields',\n // The typed DatasetModel, and exactly what `effectiveDatasetModel` reads\n // below. Drop it and every restored dataset silently degrades to the\n // derived all-text v1 model, losing types, `required`, `validation`,\n // `default` and every `reference` link (AGL-180).\n 'model',\n 'names',\n 'description',\n 'source',\n 'installedFrom',\n 'detachedFrom',\n]\n\n/**\n * The keys a restore writes back on a record. `order` is what\n * `sortDatasetRecords` sorts by, so dropping it reorders every repeatable on\n * the restored site. The integrity index is DERIVED on the way in, never\n * carried (see {@link importSiteDatasets}).\n */\nexport const RECORD_RESTORE_FIELDS: readonly string[] = ['values', 'order']\n\n/** One of a bundle's documents, reduced to the keys a restore may write. */\nfunction restorable(fields: readonly string[], input: SiteBundleItem): Record<string, unknown> {\n const allowed = new Set(fields)\n const out: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(input ?? {})) {\n // Only `undefined` is absence — Firestore rejects it outright.\n if (allowed.has(key) && value !== undefined) out[key] = value\n }\n return out\n}\n\n/** The ids a bundle would write — distinct, since two items sharing one are one document. */\nfunction bundleIds(items: readonly SiteBundleItem[]): Set<string> {\n const ids = new Set<string>()\n for (const item of items) if (item?.$id) ids.add(String(item.$id))\n return ids\n}\n\nconst datasetsOf = (orgId: string) =>\n firebaseAdmin.app().firestore().collection('orgs').doc(orgId).collection('datasets')\n\n/**\n * The site's share: the datasets the site may see, each with its records.\n *\n * A site with no organization genuinely has no datasets — a known-empty\n * answer. Everything else THROWS: a catch-all would ship a silently empty,\n * still-successful \"backup\" that nobody discovers until they restore it. An\n * export that fails loudly is recoverable; one that lies is not.\n *\n * `visibleTo` is stripped on the way out. Its `host:` tokens name sites of\n * THIS organization, so they are noise at best and a dangling reference once\n * the bundle is restored elsewhere; the restore assigns a fresh scope.\n */\nexport async function exportSiteDatasets({\n hostId,\n orgId,\n limit,\n}: SiteBundleRequest): Promise<SiteBundleItem[]> {\n if (!orgId) return []\n const snapshot = await scopedToHost(datasetsOf(orgId), hostId).limit(limit).get()\n const live = snapshot.docs.filter((doc) => !doc.get('deletedAt'))\n return Promise.all(\n live.map(async (doc) => {\n const { visibleTo: _visibleTo, ...data } = doc.data()\n const records = await doc.ref.collection('records').limit(DATASET_BUNDLE_RECORD_LIMIT).get()\n return {\n $id: doc.id,\n ...data,\n records: records.docs.map((record) => ({ $id: record.id, ...record.data() })),\n }\n }),\n )\n}\n\n/**\n * Refuse a bundle that would put the workspace over the datasets it may hold\n * (AGL-1403) — decided before the restore writes anything.\n *\n * Datasets are SOLD: the plan includes some and the workspace buys more as\n * add-ons, so `checkDatasetQuota` decides, never the plan's included number —\n * a workspace that paid for extra datasets is entitled to them, and refusing\n * its own backup after taking its money for the room would be wrong.\n *\n * A restore is identified by id COLLISION, never by the bundle's own claim of\n * where it came from (an unsigned string in a file the metered party\n * uploads). The meter is per organization, so a collision here means the\n * WORKSPACE already holds the dataset: restoring into a sibling site of the\n * same workspace rewrites datasets it already pays for and raises nothing,\n * while a copy into a different workspace collides on nothing and is refused\n * when it lands that workspace over. What is refused is the RAISE, never the\n * state of being over — a workspace already over its limit may still restore\n * its own backup.\n */\nexport async function siteDatasetsRefusal({\n orgId,\n org,\n items,\n}: SiteBundleRestoreRequest): Promise<string | null> {\n const bundled = bundleIds(items)\n if (!orgId || !bundled.size) return null\n if (!Number.isFinite(checkDatasetQuota(org as any, 0).limit)) return null\n\n // A field mask with no fields projects to the document id alone.\n const held = await datasetsOf(orgId).select().get()\n const existing = new Set(held.docs.map((doc) => doc.id))\n const next = new Set([...existing, ...bundled]).size\n // `allowed` asks \"may I add one more to N\", so the post state N is checked\n // as `N - 1` — the idiom the dataset routes' own bulk paths use.\n const quota = checkDatasetQuota(org as any, next - 1)\n if (next <= existing.size || quota.allowed) return null\n\n return (\n `This backup holds ${bundled.size} datasets and this workspace ` +\n `has ${existing.size}, which would put it at ${next} of ${quota.limit} ` +\n 'datasets. Nothing was imported — ' +\n (quota.upgradeRequired\n ? 'upgrade in Billing.'\n : `add extra datasets for $${quota.addonPriceUsd}/mo each, or ` +\n 'upgrade in Billing.')\n )\n}\n\n/**\n * Writes the bundle's datasets into the owning organization, scoped to the\n * site restored into (AGL-237, AGL-1046), each with its records.\n *\n * Non-conforming records are restored AND reported (AGL-182): data is never\n * silently dropped, and the report tells the owner what to fix. A bundle from\n * before typed models validates through the derived text model, as the live\n * migration does, so everything in it passes.\n */\nexport async function importSiteDatasets(\n request: SiteBundleImportRequest,\n): Promise<SiteBundleReportRow[]> {\n const { hostId, orgId, items, write, stamps } = request\n if (!orgId) return []\n // Through the scoped-create gate (AGL-1478, AGL-1484): a restore CREATES\n // datasets, and a dataset is born scoped to the site it lands in.\n const scope = newResourceScopeFields([hostScopeToken(hostId)])\n const report: SiteBundleReportRow[] = []\n for (const item of items) {\n if (!item?.$id) continue\n const path = `orgs/${orgId}/datasets/${String(item.$id)}`\n await write(path, { ...restorable(DATASET_RESTORE_FIELDS, item), ...stamps(), ...scope })\n const model = effectiveDatasetModel(item)\n await loadCustomFieldTypes(model, request.loadPluginSurfaces)\n const records: SiteBundleItem[] = Array.isArray(item['records']) ? item['records'] : []\n for (const record of records.slice(0, DATASET_BUNDLE_RECORD_LIMIT)) {\n if (!record?.$id) continue\n const errors = validateDocument(model, record['values'] ?? {})\n if (Object.keys(errors).length) {\n report.push({ datasetId: String(item.$id), recordId: String(record.$id), errors })\n }\n await write(`${path}/records/${String(record.$id)}`, {\n ...restorable(RECORD_RESTORE_FIELDS, record),\n ...stamps(),\n // DERIVED here rather than carried in the bundle. The integrity\n // index the delete check queries must describe the values just\n // restored, and a bundle written before the field existed carries\n // none — accepting the bundle's copy would restore records the check\n // cannot reach. Records keep their original ids, so the ids it holds\n // still resolve.\n ...datasetIntegrityFields(model, record['values'] ?? {}),\n })\n }\n }\n return report\n}\n"],"names":["datasetIntegrityFields","effectiveDatasetModel","validateDocument","checkDatasetQuota","hostScopeToken","newResourceScopeFields","firebaseAdmin","scopedToHost","loadCustomFieldTypes","DATASET_BUNDLE_RECORD_LIMIT","DATASET_RESTORE_FIELDS","RECORD_RESTORE_FIELDS","restorable","fields","input","allowed","Set","out","key","value","Object","entries","has","undefined","bundleIds","items","ids","item","$id","add","String","datasetsOf","orgId","app","firestore","collection","doc","exportSiteDatasets","hostId","limit","snapshot","get","live","docs","filter","Promise","all","map","data","visibleTo","_visibleTo","records","ref","id","record","siteDatasetsRefusal","org","bundled","size","Number","isFinite","held","select","existing","next","quota","upgradeRequired","addonPriceUsd","importSiteDatasets","request","write","stamps","scope","report","path","model","loadPluginSurfaces","Array","isArray","slice","errors","keys","length","push","datasetId","recordId"],"mappings":"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"}
@@ -0,0 +1,24 @@
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 { PluginUsageAxesDeclaration } from '@aglyn/aglyn/plugin-manager/plugin-usage-axes';
18
+ /**
19
+ * THE DATA PLUGIN'S METER (AGL-3080): what the workspace's datasets store,
20
+ * measured in the monthly usage sweep (`server/dataset-storage-meter.ts`).
21
+ * The band and the cost axis it feeds are the platform's data storage — the
22
+ * plugin measures, the plan prices — so the meter is all it declares.
23
+ */
24
+ export declare function dataUsageAxes(): PluginUsageAxesDeclaration;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { DATASET_STORAGE_METER_ID } from "./constants/bundle-common.js";
17
+ /**
18
+ * THE DATA PLUGIN'S METER (AGL-3080): what the workspace's datasets store,
19
+ * measured in the monthly usage sweep (`server/dataset-storage-meter.ts`).
20
+ * The band and the cost axis it feeds are the platform's data storage — the
21
+ * plugin measures, the plan prices — so the meter is all it declares.
22
+ */ export function dataUsageAxes() {
23
+ return {
24
+ meters: [
25
+ {
26
+ id: DATASET_STORAGE_METER_ID
27
+ }
28
+ ]
29
+ };
30
+ }
31
+
32
+ //# sourceMappingURL=usage-axes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/plugins/data/src/lib/usage-axes.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginUsageAxesDeclaration } from '@aglyn/aglyn/plugin-manager/plugin-usage-axes'\nimport { DATASET_STORAGE_METER_ID } from './constants/bundle-common'\n\n/**\n * THE DATA PLUGIN'S METER (AGL-3080): what the workspace's datasets store,\n * measured in the monthly usage sweep (`server/dataset-storage-meter.ts`).\n * The band and the cost axis it feeds are the platform's data storage — the\n * plugin measures, the plan prices — so the meter is all it declares.\n */\nexport function dataUsageAxes(): PluginUsageAxesDeclaration {\n return {\n meters: [{ id: DATASET_STORAGE_METER_ID }],\n }\n}\n"],"names":["DATASET_STORAGE_METER_ID","dataUsageAxes","meters","id"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAGD,SAASA,wBAAwB,QAAQ,+BAA2B;AAEpE;;;;;CAKC,GACD,OAAO,SAASC;IACd,OAAO;QACLC,QAAQ;YAAC;gBAAEC,IAAIH;YAAyB;SAAE;IAC5C;AACF"}