@pithy-sh/core 0.10.0 → 0.12.0
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/dist/access/access.d.ts +172 -0
- package/dist/access/access.d.ts.map +1 -0
- package/dist/access/access.js +147 -0
- package/dist/access/acting.d.ts +49 -0
- package/dist/access/acting.d.ts.map +1 -0
- package/dist/access/acting.js +21 -0
- package/dist/access/policyCheck.d.ts +33 -0
- package/dist/access/policyCheck.d.ts.map +1 -0
- package/dist/access/policyCheck.js +48 -0
- package/dist/access/usage.d.ts +83 -0
- package/dist/access/usage.d.ts.map +1 -0
- package/dist/access/usage.js +104 -0
- package/dist/access/visibility.d.ts +66 -0
- package/dist/access/visibility.d.ts.map +1 -0
- package/dist/access/visibility.js +86 -0
- package/dist/audit/auditEvent.d.ts +5 -0
- package/dist/audit/auditEvent.d.ts.map +1 -1
- package/dist/audit/auditEvent.js +2 -0
- package/dist/audit/recorder.d.ts +10 -2
- package/dist/audit/recorder.d.ts.map +1 -1
- package/dist/capability/bindings.d.ts +47 -2
- package/dist/capability/bindings.d.ts.map +1 -1
- package/dist/capability/bindings.js +117 -7
- package/dist/capability/capability.d.ts +110 -28
- package/dist/capability/capability.d.ts.map +1 -1
- package/dist/capability/capability.js +2 -0
- package/dist/capability/manifest.d.ts +2 -1
- package/dist/capability/manifest.d.ts.map +1 -1
- package/dist/capability/manifest.js +1 -1
- package/dist/capability/purge.d.ts +80 -0
- package/dist/capability/purge.d.ts.map +1 -0
- package/dist/capability/purge.js +76 -0
- package/dist/capability/settings.d.ts +86 -2
- package/dist/capability/settings.d.ts.map +1 -1
- package/dist/capability/settings.js +80 -1
- package/dist/cli/commandCatalog.generated.d.ts +238 -24
- package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
- package/dist/cli/commandCatalog.generated.js +251 -22
- package/dist/cli/commandFacts.d.ts +2 -1
- package/dist/cli/commandFacts.d.ts.map +1 -1
- package/dist/cli/commandFacts.js +2 -0
- package/dist/controlPlane/capability.d.ts.map +1 -1
- package/dist/controlPlane/capability.js +19 -2
- package/dist/controlPlane/context.d.ts +5 -0
- package/dist/controlPlane/context.d.ts.map +1 -1
- package/dist/controlPlane/context.js +3 -1
- package/dist/controlPlane/data/connection.d.ts +49 -12
- package/dist/controlPlane/data/connection.d.ts.map +1 -1
- package/dist/controlPlane/data/connection.js +56 -2
- package/dist/controlPlane/discovery/adminRoute.d.ts +29 -0
- package/dist/controlPlane/discovery/adminRoute.d.ts.map +1 -1
- package/dist/controlPlane/discovery/adminRoute.js +3 -0
- package/dist/controlPlane/error/errors.d.ts +4 -3
- package/dist/controlPlane/error/errors.d.ts.map +1 -1
- package/dist/controlPlane/http/cors.d.ts +4 -4
- package/dist/controlPlane/http/cors.js +4 -4
- package/dist/controlPlane/http/guard.d.ts +11 -0
- package/dist/controlPlane/http/guard.d.ts.map +1 -1
- package/dist/controlPlane/http/guard.js +17 -1
- package/dist/controlPlane/http/handlers.d.ts +139 -0
- package/dist/controlPlane/http/handlers.d.ts.map +1 -1
- package/dist/controlPlane/http/handlers.js +67 -4
- package/dist/controlPlane/http/readiness.d.ts +33 -0
- package/dist/controlPlane/http/readiness.d.ts.map +1 -0
- package/dist/controlPlane/http/readiness.js +39 -0
- package/dist/controlPlane/http/responses.d.ts +245 -0
- package/dist/controlPlane/http/responses.d.ts.map +1 -1
- package/dist/controlPlane/http/responses.js +101 -4
- package/dist/controlPlane/http/routes.d.ts +2 -0
- package/dist/controlPlane/http/routes.d.ts.map +1 -1
- package/dist/controlPlane/http/routes.js +31 -3
- package/dist/controlPlane/http/verify.d.ts.map +1 -1
- package/dist/controlPlane/http/verify.js +2 -1
- package/dist/controlPlane/migrationState/migrationState.d.ts +102 -0
- package/dist/controlPlane/migrationState/migrationState.d.ts.map +1 -0
- package/dist/controlPlane/migrationState/migrationState.js +142 -0
- package/dist/controlPlane/migrations/0003_connection_principal.d.ts +21 -0
- package/dist/controlPlane/migrations/0003_connection_principal.d.ts.map +1 -0
- package/dist/controlPlane/migrations/0003_connection_principal.js +33 -0
- package/dist/controlPlane/scope/scope.d.ts +23 -2
- package/dist/controlPlane/scope/scope.d.ts.map +1 -1
- package/dist/controlPlane/scope/scope.js +24 -3
- package/dist/createBackend.d.ts.map +1 -1
- package/dist/createBackend.js +9 -2
- package/dist/createEntrypoint.d.ts.map +1 -1
- package/dist/createEntrypoint.js +5 -3
- package/dist/crypto/envelope.d.ts +86 -0
- package/dist/crypto/envelope.d.ts.map +1 -0
- package/dist/crypto/envelope.js +129 -0
- package/dist/crypto/fieldKeys.d.ts +41 -0
- package/dist/crypto/fieldKeys.d.ts.map +1 -0
- package/dist/crypto/fieldKeys.js +56 -0
- package/dist/data/codecs.d.ts +23 -10
- package/dist/data/codecs.d.ts.map +1 -1
- package/dist/data/codecs.js +50 -36
- package/dist/entitlement/allowance.d.ts +25 -0
- package/dist/entitlement/allowance.d.ts.map +1 -0
- package/dist/entitlement/allowance.js +80 -0
- package/dist/entitlement/entitlement.d.ts +41 -0
- package/dist/entitlement/entitlement.d.ts.map +1 -1
- package/dist/entitlement/meter.d.ts +57 -0
- package/dist/entitlement/meter.d.ts.map +1 -0
- package/dist/entitlement/meter.js +13 -0
- package/dist/error/extend.d.ts +6 -5
- package/dist/error/extend.d.ts.map +1 -1
- package/dist/error/http.d.ts +168 -0
- package/dist/error/http.d.ts.map +1 -1
- package/dist/error/payload.d.ts +336 -0
- package/dist/error/payload.d.ts.map +1 -1
- package/dist/error/payload.js +116 -4
- package/dist/error/pithyError.d.ts +18 -7
- package/dist/error/pithyError.d.ts.map +1 -1
- package/dist/error/pithyError.js +6 -0
- package/dist/http/authContext.d.ts +7 -0
- package/dist/http/authContext.d.ts.map +1 -1
- package/dist/http/authContext.js +8 -1
- package/dist/http/scopeGates.d.ts +20 -0
- package/dist/http/scopeGates.d.ts.map +1 -0
- package/dist/http/scopeGates.js +36 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/logger/logger.d.ts +12 -5
- package/dist/logger/logger.d.ts.map +1 -1
- package/dist/logger/record.d.ts +98 -0
- package/dist/logger/record.d.ts.map +1 -1
- package/dist/migrations/capabilitySets.d.ts +23 -0
- package/dist/migrations/capabilitySets.d.ts.map +1 -0
- package/dist/migrations/capabilitySets.js +74 -0
- package/dist/migrations/registry.d.ts +26 -0
- package/dist/migrations/registry.d.ts.map +1 -1
- package/dist/migrations/registry.js +38 -7
- package/dist/migrations/state.d.ts +60 -0
- package/dist/migrations/state.d.ts.map +1 -0
- package/dist/migrations/state.js +46 -0
- package/dist/naming/feature.d.ts +27 -5
- package/dist/naming/feature.d.ts.map +1 -1
- package/dist/naming/feature.js +36 -6
- package/dist/naming/limits.d.ts +47 -3
- package/dist/naming/limits.d.ts.map +1 -1
- package/dist/naming/limits.js +47 -3
- package/dist/naming/provisionScope.d.ts +6 -0
- package/dist/naming/provisionScope.d.ts.map +1 -1
- package/dist/naming/provisionScope.js +8 -3
- package/dist/naming/resourceNames.d.ts +21 -2
- package/dist/naming/resourceNames.d.ts.map +1 -1
- package/dist/naming/resourceNames.js +21 -3
- package/dist/privacy/blindIndex.d.ts +27 -0
- package/dist/privacy/blindIndex.d.ts.map +1 -0
- package/dist/privacy/blindIndex.js +57 -0
- package/dist/privacy/classes.d.ts +142 -0
- package/dist/privacy/classes.d.ts.map +1 -0
- package/dist/privacy/classes.js +209 -0
- package/dist/privacy/classify.d.ts +336 -0
- package/dist/privacy/classify.d.ts.map +1 -0
- package/dist/privacy/classify.js +1024 -0
- package/dist/privacy/encrypted.d.ts +210 -0
- package/dist/privacy/encrypted.d.ts.map +1 -0
- package/dist/privacy/encrypted.js +350 -0
- package/dist/privacy/encryptedColumns.d.ts +64 -0
- package/dist/privacy/encryptedColumns.d.ts.map +1 -0
- package/dist/privacy/encryptedColumns.js +134 -0
- package/dist/privacy/fieldRotation.d.ts +34 -0
- package/dist/privacy/fieldRotation.d.ts.map +1 -0
- package/dist/privacy/fieldRotation.js +186 -0
- package/dist/privacy/inventory.d.ts +12 -0
- package/dist/privacy/inventory.d.ts.map +1 -0
- package/dist/privacy/inventory.js +154 -0
- package/dist/privacy/posture.d.ts +114 -0
- package/dist/privacy/posture.d.ts.map +1 -0
- package/dist/privacy/posture.js +136 -0
- package/dist/privacy/redact.d.ts +7 -0
- package/dist/privacy/redact.d.ts.map +1 -0
- package/dist/privacy/redact.js +404 -0
- package/dist/privacy/report.d.ts +58 -0
- package/dist/privacy/report.d.ts.map +1 -0
- package/dist/privacy/report.js +15 -0
- package/dist/privacy/retirement.d.ts +68 -0
- package/dist/privacy/retirement.d.ts.map +1 -0
- package/dist/privacy/retirement.js +72 -0
- package/dist/privacy/sealed.d.ts +87 -0
- package/dist/privacy/sealed.d.ts.map +1 -0
- package/dist/privacy/sealed.js +221 -0
- package/dist/privacy/types.d.ts +559 -0
- package/dist/privacy/types.d.ts.map +1 -0
- package/dist/privacy/types.js +2 -0
- package/dist/seed/writeD1.d.ts +7 -1
- package/dist/seed/writeD1.d.ts.map +1 -1
- package/dist/seed/writeD1.js +9 -3
- package/dist/seed/writeKv.d.ts +2 -0
- package/dist/seed/writeKv.d.ts.map +1 -1
- package/dist/seed/writeKv.js +6 -2
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/worker/serviceLoopback.d.ts +36 -0
- package/dist/worker/serviceLoopback.d.ts.map +1 -0
- package/dist/worker/serviceLoopback.js +74 -0
- package/dist/workflow/faults.d.ts +12 -1
- package/dist/workflow/faults.d.ts.map +1 -1
- package/dist/workflow/faults.js +22 -15
- package/package.json +2 -2
- package/src/access/access.ts +298 -0
- package/src/access/acting.ts +59 -0
- package/src/access/policyCheck.ts +77 -0
- package/src/access/usage.ts +141 -0
- package/src/access/visibility.ts +109 -0
- package/src/audit/auditEvent.ts +4 -0
- package/src/audit/recorder.ts +10 -2
- package/src/capability/bindings.ts +159 -13
- package/src/capability/capability.ts +114 -29
- package/src/capability/purge.ts +124 -0
- package/src/capability/settings.ts +146 -2
- package/src/cli/commandCatalog.generated.ts +271 -22
- package/src/cli/commandFacts.ts +2 -0
- package/src/controlPlane/capability.ts +36 -1
- package/src/controlPlane/context.ts +4 -0
- package/src/controlPlane/data/connection.ts +90 -0
- package/src/controlPlane/discovery/adminRoute.ts +11 -0
- package/src/controlPlane/error/errors.ts +4 -3
- package/src/controlPlane/http/cors.ts +4 -4
- package/src/controlPlane/http/guard.ts +21 -0
- package/src/controlPlane/http/handlers.ts +98 -4
- package/src/controlPlane/http/readiness.ts +50 -0
- package/src/controlPlane/http/responses.ts +240 -2
- package/src/controlPlane/http/routes.ts +55 -1
- package/src/controlPlane/http/verify.ts +2 -0
- package/src/controlPlane/migrationState/migrationState.ts +200 -0
- package/src/controlPlane/migrations/0003_connection_principal.ts +37 -0
- package/src/controlPlane/scope/scope.ts +25 -2
- package/src/controlPlane/token/base64url.ts +1 -1
- package/src/createBackend.ts +28 -6
- package/src/createEntrypoint.ts +9 -3
- package/src/crypto/envelope.ts +196 -0
- package/src/crypto/fieldKeys.ts +76 -0
- package/src/data/codecs.ts +69 -37
- package/src/entitlement/allowance.ts +85 -0
- package/src/entitlement/entitlement.ts +38 -0
- package/src/entitlement/meter.ts +69 -0
- package/src/error/extend.ts +6 -5
- package/src/error/payload.ts +253 -4
- package/src/error/pithyError.ts +21 -7
- package/src/http/authContext.ts +13 -1
- package/src/http/scopeGates.ts +34 -0
- package/src/index.ts +2 -0
- package/src/logger/logger.ts +12 -5
- package/src/migrations/capabilitySets.ts +87 -0
- package/src/migrations/registry.ts +46 -2
- package/src/migrations/state.ts +94 -0
- package/src/naming/feature.ts +46 -7
- package/src/naming/limits.ts +35 -3
- package/src/naming/provisionScope.ts +19 -1
- package/src/naming/resourceNames.ts +52 -5
- package/src/privacy/blindIndex.ts +81 -0
- package/src/privacy/classes.ts +277 -0
- package/src/privacy/classify.ts +1447 -0
- package/src/privacy/encrypted.ts +513 -0
- package/src/privacy/encryptedColumns.ts +185 -0
- package/src/privacy/fieldRotation.ts +234 -0
- package/src/privacy/inventory.ts +147 -0
- package/src/privacy/posture.ts +205 -0
- package/src/privacy/redact.ts +423 -0
- package/src/privacy/report.ts +64 -0
- package/src/privacy/retirement.ts +102 -0
- package/src/privacy/sealed.ts +323 -0
- package/src/privacy/types.ts +804 -0
- package/src/seed/writeD1.ts +13 -4
- package/src/seed/writeKv.ts +6 -2
- package/src/version.generated.ts +1 -1
- package/src/worker/serviceLoopback.ts +105 -0
- package/src/workflow/faults.ts +51 -24
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"writeD1.d.ts","sourceRoot":"","sources":["../../src/seed/writeD1.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACrC,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AAE1C,uCAAuC;AACvC,MAAM,WAAW,aAAa;IAC5B,+CAA+C;IAC/C,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,2CAA2C;AAC3C,MAAM,WAAW,YAAY;IAC3B,wBAAwB;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,
|
|
1
|
+
{"version":3,"file":"writeD1.d.ts","sourceRoot":"","sources":["../../src/seed/writeD1.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACrC,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AAE1C,uCAAuC;AACvC,MAAM,WAAW,aAAa;IAC5B,+CAA+C;IAC/C,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,2CAA2C;AAC3C,MAAM,WAAW,YAAY;IAC3B,wBAAwB;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,iEAAiE;IACjE,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,OAAO,EAAE,MAAM,CAAC;CACjB;AAqBD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAsB,WAAW,CAE/B,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,EACf,KAAK,EAAE,WAAW,EAClB,MAAM,EAAE,CAAC,CAAC,OAAO,EACjB,OAAO,GAAE,aAAkB,GAC1B,OAAO,CAAC,YAAY,CAAC,CA+BvB"}
|
package/dist/seed/writeD1.js
CHANGED
|
@@ -63,12 +63,18 @@ async function seedD1Group(db, group, schema, options = {}) {
|
|
|
63
63
|
});
|
|
64
64
|
if (options.dryRun || encoded.length === 0) return {
|
|
65
65
|
table: group.table,
|
|
66
|
-
rows: encoded.length
|
|
66
|
+
rows: encoded.length,
|
|
67
|
+
written: 0
|
|
67
68
|
};
|
|
68
|
-
|
|
69
|
+
let written = 0n;
|
|
70
|
+
for (const chunk of chunkRowsByBoundParameters(encoded, columnsPerRow(encoded))) {
|
|
71
|
+
const results = await db.insertInto(group.table).orIgnore().values(chunk).execute();
|
|
72
|
+
for (const result of results) written += result.numInsertedOrUpdatedRows ?? 0n;
|
|
73
|
+
}
|
|
69
74
|
return {
|
|
70
75
|
table: group.table,
|
|
71
|
-
rows: encoded.length
|
|
76
|
+
rows: encoded.length,
|
|
77
|
+
written: Number(written)
|
|
72
78
|
};
|
|
73
79
|
}
|
|
74
80
|
//#endregion
|
package/dist/seed/writeKv.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface SeedKvResult {
|
|
|
15
15
|
store: string;
|
|
16
16
|
/** The number of entries validated (and, unless `dryRun`, considered for writing). */
|
|
17
17
|
entries: number;
|
|
18
|
+
/** The number of entries this call put. An existing key is left alone and not counted (#764). Always `0` under `dryRun`. */
|
|
19
|
+
written: number;
|
|
18
20
|
}
|
|
19
21
|
/**
|
|
20
22
|
* Seed one KV group, idempotently and non-destructively.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"writeKv.d.ts","sourceRoot":"","sources":["../../src/seed/writeKv.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AACxC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AAE1C,uCAAuC;AACvC,MAAM,WAAW,aAAa;IAC5B,oCAAoC;IACpC,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,2CAA2C;AAC3C,MAAM,WAAW,YAAY;IAC3B,wBAAwB;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,sFAAsF;IACtF,OAAO,EAAE,MAAM,CAAC;CACjB;AA4CD;;;;;;;;;;GAUG;AACH,wBAAsB,WAAW,CAAC,CAAC,SAAS,CAAC,CAAC,OAAO,EAAE,CAAC,SAAS,CAAC,CAAC,SAAS,EAAE,CAAC,SAAS,CAAC,CAAC,OAAO,EAC/F,KAAK,EAAE,OAAO,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EACvB,KAAK,EAAE,WAAW,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EAC3B,IAAI,EAAE,WAAW,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EAC1B,OAAO,GAAE,aAAkB,GAC1B,OAAO,CAAC,YAAY,CAAC,
|
|
1
|
+
{"version":3,"file":"writeKv.d.ts","sourceRoot":"","sources":["../../src/seed/writeKv.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AACxC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AAE1C,uCAAuC;AACvC,MAAM,WAAW,aAAa;IAC5B,oCAAoC;IACpC,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,2CAA2C;AAC3C,MAAM,WAAW,YAAY;IAC3B,wBAAwB;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,sFAAsF;IACtF,OAAO,EAAE,MAAM,CAAC;IAChB,4HAA4H;IAC5H,OAAO,EAAE,MAAM,CAAC;CACjB;AA4CD;;;;;;;;;;GAUG;AACH,wBAAsB,WAAW,CAAC,CAAC,SAAS,CAAC,CAAC,OAAO,EAAE,CAAC,SAAS,CAAC,CAAC,SAAS,EAAE,CAAC,SAAS,CAAC,CAAC,OAAO,EAC/F,KAAK,EAAE,OAAO,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EACvB,KAAK,EAAE,WAAW,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EAC3B,IAAI,EAAE,WAAW,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EAC1B,OAAO,GAAE,aAAkB,GAC1B,OAAO,CAAC,YAAY,CAAC,CAmBvB"}
|
package/dist/seed/writeKv.js
CHANGED
|
@@ -50,15 +50,19 @@ async function seedKvGroup(store, group, spec, options = {}) {
|
|
|
50
50
|
});
|
|
51
51
|
if (options.dryRun) return {
|
|
52
52
|
store: group.store,
|
|
53
|
-
entries: group.entries.length
|
|
53
|
+
entries: group.entries.length,
|
|
54
|
+
written: 0
|
|
54
55
|
};
|
|
56
|
+
let written = 0;
|
|
55
57
|
for (const entry of group.entries) {
|
|
56
58
|
if (await keyExists(store, entry.key)) continue;
|
|
57
59
|
await store.put(entry.key, entry.value, entry.metadata !== void 0 ? { metadata: entry.metadata } : {});
|
|
60
|
+
written += 1;
|
|
58
61
|
}
|
|
59
62
|
return {
|
|
60
63
|
store: group.store,
|
|
61
|
-
entries: group.entries.length
|
|
64
|
+
entries: group.entries.length,
|
|
65
|
+
written
|
|
62
66
|
};
|
|
63
67
|
}
|
|
64
68
|
//#endregion
|
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
/** This package's npm name — the join key against a release feed. */
|
|
4
4
|
export declare const PACKAGE_NAME = "@pithy-sh/core";
|
|
5
5
|
/** This package's version, stamped from its own package.json at generation time. */
|
|
6
|
-
export declare const PACKAGE_VERSION = "0.
|
|
6
|
+
export declare const PACKAGE_VERSION = "0.12.0";
|
|
7
7
|
//# sourceMappingURL=version.generated.d.ts.map
|
|
@@ -4,6 +4,6 @@
|
|
|
4
4
|
/** This package's npm name — the join key against a release feed. */
|
|
5
5
|
const PACKAGE_NAME = "@pithy-sh/core";
|
|
6
6
|
/** This package's version, stamped from its own package.json at generation time. */
|
|
7
|
-
const PACKAGE_VERSION = "0.
|
|
7
|
+
const PACKAGE_VERSION = "0.12.0";
|
|
8
8
|
//#endregion
|
|
9
9
|
export { PACKAGE_NAME, PACKAGE_VERSION };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* **A service binding under `pithy dev`: the call reaches the local target on its pinned port (#617).**
|
|
5
|
+
*
|
|
6
|
+
* Deployed, `env.APP_B.fetch(request)` is a service binding: Cloudflare runs the call inside the runtime, into
|
|
7
|
+
* the script the binding names. Locally there is no such script. `wrangler dev` would look for one in its own
|
|
8
|
+
* cross-process registry — the one `pithy dev` never relies on — and a Worker started on its own with
|
|
9
|
+
* `pithy dev --app app-a` would find nothing there at all. What does exist is the target itself, on the port its
|
|
10
|
+
* feature pinned for it, and `pithy dev` knows that address before anything starts.
|
|
11
|
+
*
|
|
12
|
+
* So `pithy dev` publishes one var, {@link SERVICE_ORIGINS_VAR}: a JSON map from each service binding this
|
|
13
|
+
* Worker declares to its target's local origin. Under `dev`, and only there, each of those bindings is replaced
|
|
14
|
+
* with a stand-in whose `fetch` sends the same request to that origin. The call site is byte-identical either
|
|
15
|
+
* way. A target that is not running refuses the connection, so the call fails at once as
|
|
16
|
+
* `core/upstream_failed`, naming the binding and the address, rather than hanging on a registry lookup.
|
|
17
|
+
*
|
|
18
|
+
* **Only `dev`, and only what was published.** The var is written by `pithy dev` and by nothing else, and the
|
|
19
|
+
* gate reads `ENVIRONMENT` off the request env as the Workflow loopback does (`workflow/dispatch.ts`): a
|
|
20
|
+
* deployment that lost its `ENVIRONMENT` var never opens it.
|
|
21
|
+
*/
|
|
22
|
+
/** The var `pithy dev` publishes: each service binding's local target, as JSON. */
|
|
23
|
+
export declare const SERVICE_ORIGINS_VAR = "PITHY_SERVICE_ORIGINS";
|
|
24
|
+
/** The transport. The runtime's `fetch`, or a test's. */
|
|
25
|
+
export type ServiceLoopbackFetch = (request: Request) => Promise<Response>;
|
|
26
|
+
/** Options for {@link withLocalServices}. */
|
|
27
|
+
export interface LocalServicesOptions {
|
|
28
|
+
/** The transport. Defaults to the runtime's `fetch`. */
|
|
29
|
+
fetch?: ServiceLoopbackFetch;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The env a request runs with: under `pithy dev`, each published service binding replaced with its local
|
|
33
|
+
* stand-in; anywhere else, the env unchanged — the same object, so nothing outside `dev` pays for this.
|
|
34
|
+
*/
|
|
35
|
+
export declare function withLocalServices(env: Record<string, unknown>, options?: LocalServicesOptions): Record<string, unknown>;
|
|
36
|
+
//# sourceMappingURL=serviceLoopback.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"serviceLoopback.d.ts","sourceRoot":"","sources":["../../src/worker/serviceLoopback.ts"],"names":[],"mappings":"AAQA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,mFAAmF;AACnF,eAAO,MAAM,mBAAmB,0BAA0B,CAAC;AAa3D,yDAAyD;AACzD,MAAM,MAAM,oBAAoB,GAAG,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAgC3E,6CAA6C;AAC7C,MAAM,WAAW,oBAAoB;IACnC,wDAAwD;IACxD,KAAK,CAAC,EAAE,oBAAoB,CAAC;CAC9B;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC5B,OAAO,GAAE,oBAAyB,GACjC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAgBzB"}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { UpstreamError } from "../error/pithyError.js";
|
|
4
|
+
import { commandName } from "../cli/command.js";
|
|
5
|
+
import "./identity.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
//#region src/worker/serviceLoopback.ts
|
|
8
|
+
/**
|
|
9
|
+
* **A service binding under `pithy dev`: the call reaches the local target on its pinned port (#617).**
|
|
10
|
+
*
|
|
11
|
+
* Deployed, `env.APP_B.fetch(request)` is a service binding: Cloudflare runs the call inside the runtime, into
|
|
12
|
+
* the script the binding names. Locally there is no such script. `wrangler dev` would look for one in its own
|
|
13
|
+
* cross-process registry — the one `pithy dev` never relies on — and a Worker started on its own with
|
|
14
|
+
* `pithy dev --app app-a` would find nothing there at all. What does exist is the target itself, on the port its
|
|
15
|
+
* feature pinned for it, and `pithy dev` knows that address before anything starts.
|
|
16
|
+
*
|
|
17
|
+
* So `pithy dev` publishes one var, {@link SERVICE_ORIGINS_VAR}: a JSON map from each service binding this
|
|
18
|
+
* Worker declares to its target's local origin. Under `dev`, and only there, each of those bindings is replaced
|
|
19
|
+
* with a stand-in whose `fetch` sends the same request to that origin. The call site is byte-identical either
|
|
20
|
+
* way. A target that is not running refuses the connection, so the call fails at once as
|
|
21
|
+
* `core/upstream_failed`, naming the binding and the address, rather than hanging on a registry lookup.
|
|
22
|
+
*
|
|
23
|
+
* **Only `dev`, and only what was published.** The var is written by `pithy dev` and by nothing else, and the
|
|
24
|
+
* gate reads `ENVIRONMENT` off the request env as the Workflow loopback does (`workflow/dispatch.ts`): a
|
|
25
|
+
* deployment that lost its `ENVIRONMENT` var never opens it.
|
|
26
|
+
*/
|
|
27
|
+
/** The var `pithy dev` publishes: each service binding's local target, as JSON. */
|
|
28
|
+
const SERVICE_ORIGINS_VAR = "PITHY_SERVICE_ORIGINS";
|
|
29
|
+
/** The one environment the stand-in is made in. */
|
|
30
|
+
const LOCAL = "dev";
|
|
31
|
+
/** What the var carries: binding name to an `http(s)` origin. */
|
|
32
|
+
const ServiceOrigins = z.record(z.string().describe("A service binding's name in the Worker env."), z.url({ protocol: /^https?$/ }).describe("The local origin of the Worker it calls, as `pithy dev` pinned it.")).describe("Each service binding's local target, published by `pithy dev`.");
|
|
33
|
+
/** A stand-in for one service binding: every request sent to `origin`, its path, method, headers and body kept. */
|
|
34
|
+
function localService(binding, origin, send) {
|
|
35
|
+
const target = new URL(origin);
|
|
36
|
+
return { async fetch(input, init) {
|
|
37
|
+
const request = new Request(input, init);
|
|
38
|
+
const url = new URL(request.url);
|
|
39
|
+
url.protocol = target.protocol;
|
|
40
|
+
url.host = target.host;
|
|
41
|
+
try {
|
|
42
|
+
return await send(new Request(url, request));
|
|
43
|
+
} catch (cause) {
|
|
44
|
+
throw new UpstreamError({
|
|
45
|
+
message: `The Worker ${binding} calls is not answering.`,
|
|
46
|
+
action: `Check that it is running under ${commandName("dev")} at ${target.origin}.`,
|
|
47
|
+
detail: `${request.method} ${url.href} failed: ${String(cause)}`
|
|
48
|
+
}, { cause });
|
|
49
|
+
}
|
|
50
|
+
} };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The env a request runs with: under `pithy dev`, each published service binding replaced with its local
|
|
54
|
+
* stand-in; anywhere else, the env unchanged — the same object, so nothing outside `dev` pays for this.
|
|
55
|
+
*/
|
|
56
|
+
function withLocalServices(env, options = {}) {
|
|
57
|
+
if (env["ENVIRONMENT"] !== LOCAL) return env;
|
|
58
|
+
const published = env[SERVICE_ORIGINS_VAR];
|
|
59
|
+
if (typeof published !== "string") return env;
|
|
60
|
+
let parsed;
|
|
61
|
+
try {
|
|
62
|
+
parsed = JSON.parse(published);
|
|
63
|
+
} catch {
|
|
64
|
+
return env;
|
|
65
|
+
}
|
|
66
|
+
const origins = ServiceOrigins.safeParse(parsed);
|
|
67
|
+
if (!origins.success || Object.keys(origins.data).length === 0) return env;
|
|
68
|
+
const send = options.fetch ?? ((request) => fetch(request));
|
|
69
|
+
const local = { ...env };
|
|
70
|
+
for (const [binding, origin] of Object.entries(origins.data)) local[binding] = localService(binding, origin, send);
|
|
71
|
+
return local;
|
|
72
|
+
}
|
|
73
|
+
//#endregion
|
|
74
|
+
export { SERVICE_ORIGINS_VAR, withLocalServices };
|
|
@@ -102,6 +102,17 @@ export interface DurableStepLike extends WorkflowStepLike {
|
|
|
102
102
|
/** Pause the instance for `durationMs`, then resume. */
|
|
103
103
|
sleep(name: string, durationMs: number): Promise<void>;
|
|
104
104
|
}
|
|
105
|
+
/** What {@link classifiedSteps} hands back: a pausing runner, and a step that runs exactly once. */
|
|
106
|
+
export interface ClassifiedSteps extends DurableStepLike {
|
|
107
|
+
/**
|
|
108
|
+
* Run a named step with **no retries**, still classified (pithy-sh/pithy#602).
|
|
109
|
+
*
|
|
110
|
+
* For a body that mutates somebody else's system — a provider roll — where a platform re-running the
|
|
111
|
+
* step after an eviction would do it twice. The classification still decides what the instance's
|
|
112
|
+
* failure says; it can no longer decide on a retry, because there is none to give.
|
|
113
|
+
*/
|
|
114
|
+
once<T>(name: string, fn: () => Promise<T>): Promise<T>;
|
|
115
|
+
}
|
|
105
116
|
/**
|
|
106
117
|
* The platform's terminal-error class — `NonRetryableError` from `cloudflare:workflows`, taken as a
|
|
107
118
|
* parameter.
|
|
@@ -127,5 +138,5 @@ export interface TerminalErrorConstructor {
|
|
|
127
138
|
* `runSendBatch`, `runAtRestKeyRotation`) takes this instead with no change: the classification is
|
|
128
139
|
* declared once, at the entrypoint, and every step below it inherits the decision.
|
|
129
140
|
*/
|
|
130
|
-
export declare function classifiedSteps(step: WorkflowStepLike, policy: WorkflowRetryPolicy, terminalError: TerminalErrorConstructor):
|
|
141
|
+
export declare function classifiedSteps(step: WorkflowStepLike, policy: WorkflowRetryPolicy, terminalError: TerminalErrorConstructor): ClassifiedSteps;
|
|
131
142
|
//# sourceMappingURL=faults.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"faults.d.ts","sourceRoot":"","sources":["../../src/workflow/faults.ts"],"names":[],"mappings":"AAOA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,2FAA2F;AAC3F,MAAM,MAAM,wBAAwB,GAAG,OAAO,GAAG,UAAU,CAAC;AAE5D;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC,iHAAiH;IACjH,UAAU,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,SAAS,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CAC7C;AAED,2FAA2F;AAC3F,MAAM,WAAW,aAAa;IAC5B,sBAAsB;IACtB,WAAW,EAAE,wBAAwB,CAAC;IACtC,mFAAmF;IACnF,IAAI,EAAE,MAAM,CAAC;IACb,+EAA+E;IAC/E,MAAM,EAAE,MAAM,CAAC;CAChB;AAsDD;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,mBAAmB,GAAG,aAAa,CA0BhG;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,gBAAgB;IAC/B,+FAA+F;IAC/F,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IACtD;;;OAGG;IACH,KAAK,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACzD;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,eAAgB,SAAQ,gBAAgB;IACvD,wDAAwD;IACxD,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACxD;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,wBAAwB;IACvC,KAAK,OAAO,EAAE,MAAM,GAAG,KAAK,CAAC;CAC9B;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,gBAAgB,EACtB,MAAM,EAAE,mBAAmB,EAC3B,aAAa,EAAE,wBAAwB,GACtC,eAAe,
|
|
1
|
+
{"version":3,"file":"faults.d.ts","sourceRoot":"","sources":["../../src/workflow/faults.ts"],"names":[],"mappings":"AAOA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,2FAA2F;AAC3F,MAAM,MAAM,wBAAwB,GAAG,OAAO,GAAG,UAAU,CAAC;AAE5D;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC,iHAAiH;IACjH,UAAU,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,SAAS,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CAC7C;AAED,2FAA2F;AAC3F,MAAM,WAAW,aAAa;IAC5B,sBAAsB;IACtB,WAAW,EAAE,wBAAwB,CAAC;IACtC,mFAAmF;IACnF,IAAI,EAAE,MAAM,CAAC;IACb,+EAA+E;IAC/E,MAAM,EAAE,MAAM,CAAC;CAChB;AAsDD;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,mBAAmB,GAAG,aAAa,CA0BhG;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,gBAAgB;IAC/B,+FAA+F;IAC/F,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IACtD;;;OAGG;IACH,KAAK,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACzD;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,eAAgB,SAAQ,gBAAgB;IACvD,wDAAwD;IACxD,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACxD;AAWD,oGAAoG;AACpG,MAAM,WAAW,eAAgB,SAAQ,eAAe;IACtD;;;;;;OAMG;IACH,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;CACzD;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,wBAAwB;IACvC,KAAK,OAAO,EAAE,MAAM,GAAG,KAAK,CAAC;CAC9B;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,gBAAgB,EACtB,MAAM,EAAE,mBAAmB,EAC3B,aAAa,EAAE,wBAAwB,GACtC,eAAe,CA2DjB"}
|
package/dist/workflow/faults.js
CHANGED
|
@@ -91,23 +91,30 @@ function classifyWorkflowFault(error, policy) {
|
|
|
91
91
|
* declared once, at the entrypoint, and every step below it inherits the decision.
|
|
92
92
|
*/
|
|
93
93
|
function classifiedSteps(step, policy, terminalError) {
|
|
94
|
+
const classify = (fn) => async () => {
|
|
95
|
+
try {
|
|
96
|
+
return await fn();
|
|
97
|
+
} catch (error) {
|
|
98
|
+
const fault = classifyWorkflowFault(error, policy);
|
|
99
|
+
if (fault.disposition === "retry") throw error;
|
|
100
|
+
const terminal = new terminalError(encodeWorkflowStepMessage({
|
|
101
|
+
code: fault.code,
|
|
102
|
+
message: messageOf(error),
|
|
103
|
+
action: pithyActionOf(error)
|
|
104
|
+
}));
|
|
105
|
+
terminal.cause = error;
|
|
106
|
+
throw terminal;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
94
109
|
const runner = {
|
|
95
110
|
do(name, fn) {
|
|
96
|
-
return step.do(name,
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
code: fault.code,
|
|
104
|
-
message: messageOf(error),
|
|
105
|
-
action: pithyActionOf(error)
|
|
106
|
-
}));
|
|
107
|
-
terminal.cause = error;
|
|
108
|
-
throw terminal;
|
|
109
|
-
}
|
|
110
|
-
});
|
|
111
|
+
return step.do(name, classify(fn));
|
|
112
|
+
},
|
|
113
|
+
once(name, fn) {
|
|
114
|
+
return step.do(name, { retries: {
|
|
115
|
+
limit: 0,
|
|
116
|
+
delay: 0
|
|
117
|
+
} }, classify(fn));
|
|
111
118
|
},
|
|
112
119
|
/**
|
|
113
120
|
* Forwarded, and deliberately unclassified. A pause raises no fault of the capability's — the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pithy-sh/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"build": "tsdown && tsc -p tsconfig.build.json && bun ../../tooling/build/src/stampDeclarations.ts",
|
|
29
|
-
"typecheck": "
|
|
29
|
+
"typecheck": "bun check -p tsconfig.json",
|
|
30
30
|
"test": "vitest run",
|
|
31
31
|
"test:node": "vitest run --project=node",
|
|
32
32
|
"test:workers": "vitest run --project=workers",
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { type RawBuilder, type SqlBool, sql } from "kysely";
|
|
5
|
+
import type { PithyVars } from "../capability/capability";
|
|
6
|
+
import type { ActingOrganization } from "./acting";
|
|
7
|
+
import type { RecordVisibility } from "./visibility";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Who may read a record, and who may change it — the rules media and storage both enforce, and the one
|
|
11
|
+
* hook an adopter answers to change them.
|
|
12
|
+
*
|
|
13
|
+
* ## The rules
|
|
14
|
+
*
|
|
15
|
+
* | Level | Who reads it |
|
|
16
|
+
* |----------------|--------------------------------------------------------------------|
|
|
17
|
+
* | `private` | The owner, acting for the organization the record was written under. |
|
|
18
|
+
* | `organization` | Any member of the record's organization, acting for it. |
|
|
19
|
+
* | `public` | Anyone holding the id or URL, signed in or not. |
|
|
20
|
+
*
|
|
21
|
+
* **Writing** — editing, deleting, changing visibility — is the owner's, acting for the record's
|
|
22
|
+
* organization, and that of a role holding the capability's manage power (`media:manage`,
|
|
23
|
+
* `storage:manage`) in the record's organization, so an organization can clean up after somebody who
|
|
24
|
+
* left. The manage power admits reading too: a record you may delete is a record you may see.
|
|
25
|
+
*
|
|
26
|
+
* **The organization is compared as recorded, null included.** A record written with no organization in
|
|
27
|
+
* force is its owner's while no organization is in force, and nobody's otherwise — not a manager's, since
|
|
28
|
+
* a power is only ever held *in* an organization. A record with no owner and no organization is the
|
|
29
|
+
* system's, and no rule reaches it; the hook is the only thing that can.
|
|
30
|
+
*
|
|
31
|
+
* ## The hook: allow, deny, or the default
|
|
32
|
+
*
|
|
33
|
+
* Asked on every read and every write. **Allow** admits — sharing a record with one person. **Deny**
|
|
34
|
+
* removes — a legal hold. **Default** is the rules above, and no hook at all is the rules unchanged.
|
|
35
|
+
* Sharing itself stays the adopter's: their grants, their requests for access, their tables. The hook is
|
|
36
|
+
* handed the Worker env so it can read them.
|
|
37
|
+
*
|
|
38
|
+
* **On a list, a deny filters and a grant does not list.** The rules run in SQL ({@link
|
|
39
|
+
* readablePredicate}); the hook is asked about each row on the page ({@link keepReadable}) and may only
|
|
40
|
+
* remove one. A record the hook grants is reached by id, and the adopter lists their own grants from
|
|
41
|
+
* their own tables.
|
|
42
|
+
*
|
|
43
|
+
* **The hook governs the record routes and nothing else.** Duplicate detection and control-plane rollups
|
|
44
|
+
* keep their non-disclosure rules whatever it answers: a hook that widened a per-subject total would
|
|
45
|
+
* reopen exactly the disclosure those rules close.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/** Who is asking: the signed-in person, if any, and the organization they act for, if any. */
|
|
49
|
+
export interface AccessCaller {
|
|
50
|
+
/** The signed-in user, from `c.var.auth.userId`; `null` for an anonymous caller. */
|
|
51
|
+
readonly userId: string | null;
|
|
52
|
+
/** The organization in force, from the acting-organization seam; `null` when none is. */
|
|
53
|
+
readonly acting: ActingOrganization | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The fields of a record the rules read. Every capability holding a person's record carries these. */
|
|
57
|
+
export interface AccessRecord {
|
|
58
|
+
/** The record's id — handed to the hook so it can look up a grant. */
|
|
59
|
+
readonly id: string;
|
|
60
|
+
/** The user who owns it, or `null` for a system-owned record. */
|
|
61
|
+
readonly ownerId: string | null;
|
|
62
|
+
/** The organization in force when it was written, or `null` when none was. */
|
|
63
|
+
readonly organizationId: string | null;
|
|
64
|
+
/** Who may read it. */
|
|
65
|
+
readonly visibility: RecordVisibility;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** What the hook is asked about. Reading, or changing — editing, deleting, changing visibility. */
|
|
69
|
+
export type AccessAction = "read" | "write";
|
|
70
|
+
|
|
71
|
+
/** The hook's answer. `default` defers to the rules. */
|
|
72
|
+
export type PermissionDecision = "allow" | "deny" | "default";
|
|
73
|
+
|
|
74
|
+
/** Everything the hook is told. */
|
|
75
|
+
export interface PermissionRequest {
|
|
76
|
+
/** What the caller is trying to do. */
|
|
77
|
+
readonly action: AccessAction;
|
|
78
|
+
/** Which capability holds the record — `media` or `storage` — so one hook can serve both. */
|
|
79
|
+
readonly capability: string;
|
|
80
|
+
/** The record, as the rules see it. */
|
|
81
|
+
readonly record: AccessRecord;
|
|
82
|
+
/** Who is asking, as ids. */
|
|
83
|
+
readonly caller: { readonly userId: string | null; readonly organizationId: string | null };
|
|
84
|
+
/** The Worker env, so the hook can read the adopter's own grant tables through their own bindings. */
|
|
85
|
+
readonly env: Record<string, unknown>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The one hook an adopter supplies. May be async — a grant lives in a table. */
|
|
89
|
+
export type PermissionHook = (request: PermissionRequest) => PermissionDecision | Promise<PermissionDecision>;
|
|
90
|
+
|
|
91
|
+
/** What a caller may do with one record. */
|
|
92
|
+
export type AccessLevel = "none" | "read" | "write";
|
|
93
|
+
|
|
94
|
+
/** The organization a caller acts for, as an id. */
|
|
95
|
+
function organizationOf(caller: AccessCaller): string | null {
|
|
96
|
+
return caller.acting?.organizationId ?? null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Whether the caller acts for the organization the record was written under — null matching null. */
|
|
100
|
+
function actsForRecord(record: AccessRecord, caller: AccessCaller): boolean {
|
|
101
|
+
return record.organizationId === organizationOf(caller);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Whether the caller is the record's owner, acting for the record's organization. */
|
|
105
|
+
function isActingOwner(record: AccessRecord, caller: AccessCaller): boolean {
|
|
106
|
+
return caller.userId !== null && record.ownerId === caller.userId && actsForRecord(record, caller);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Whether the caller holds the manage power in the record's organization. Never for a null organization. */
|
|
110
|
+
function managesRecord(record: AccessRecord, caller: AccessCaller, managePower: string): boolean {
|
|
111
|
+
return (
|
|
112
|
+
caller.userId !== null &&
|
|
113
|
+
record.organizationId !== null &&
|
|
114
|
+
caller.acting !== null &&
|
|
115
|
+
caller.acting.organizationId === record.organizationId &&
|
|
116
|
+
caller.acting.roleAllows(managePower)
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The rules alone, with no hook — what `default` means. */
|
|
121
|
+
export function defaultAllows(
|
|
122
|
+
action: AccessAction,
|
|
123
|
+
record: AccessRecord,
|
|
124
|
+
caller: AccessCaller,
|
|
125
|
+
managePower: string,
|
|
126
|
+
): boolean {
|
|
127
|
+
if (isActingOwner(record, caller) || managesRecord(record, caller, managePower)) return true;
|
|
128
|
+
if (action === "write") return false;
|
|
129
|
+
switch (record.visibility) {
|
|
130
|
+
case "public":
|
|
131
|
+
return true;
|
|
132
|
+
case "organization":
|
|
133
|
+
return caller.userId !== null && record.organizationId !== null && actsForRecord(record, caller);
|
|
134
|
+
case "private":
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A record's owner and organization, as one reader may be told them. */
|
|
140
|
+
export interface DisclosedTenancy {
|
|
141
|
+
readonly ownerId: string | null;
|
|
142
|
+
readonly organizationId: string | null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Who holds a record, as this reader may learn it: the owner and organization when the reader is signed in
|
|
147
|
+
* and acts for the record's organization (none matching none), and neither otherwise.
|
|
148
|
+
*
|
|
149
|
+
* **Reading a record is not learning who holds it.** A public record is anyone's to read, but its owner's
|
|
150
|
+
* id beside its organization's id says that person belongs to that organization — and a list with
|
|
151
|
+
* `includePublic=true` reaches public records in every organization. An organization never learns that one
|
|
152
|
+
* of its people holds an account in another, so a record read from outside its organization, or by nobody
|
|
153
|
+
* in particular, names neither. Making a file public is a choice about the file, not about its owner.
|
|
154
|
+
*
|
|
155
|
+
* Every view a client sees of a media record or a stored file passes through this.
|
|
156
|
+
*/
|
|
157
|
+
export function disclosedTenancy(record: AccessRecord, caller: AccessCaller): DisclosedTenancy {
|
|
158
|
+
if (caller.userId === null || !actsForRecord(record, caller)) return { ownerId: null, organizationId: null };
|
|
159
|
+
return { ownerId: record.ownerId, organizationId: record.organizationId };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** What {@link accessLevel} needs: the record, the caller, the capability's power, and the hook if any. */
|
|
163
|
+
export interface AccessOptions {
|
|
164
|
+
/** The record being reached. */
|
|
165
|
+
readonly record: AccessRecord;
|
|
166
|
+
/** Who is reaching it. */
|
|
167
|
+
readonly caller: AccessCaller;
|
|
168
|
+
/** The capability's name, as the hook sees it. */
|
|
169
|
+
readonly capability: string;
|
|
170
|
+
/** The capability's manage power — `media:manage`, `storage:manage`. */
|
|
171
|
+
readonly managePower: string;
|
|
172
|
+
/** The adopter's hook, if they supplied one. */
|
|
173
|
+
readonly hook?: PermissionHook;
|
|
174
|
+
/** The Worker env, for the hook. */
|
|
175
|
+
readonly env: Record<string, unknown>;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Ask the hook, then the rules where it defers. */
|
|
179
|
+
async function decide(action: AccessAction, options: AccessOptions): Promise<boolean> {
|
|
180
|
+
const fallback = () => defaultAllows(action, options.record, options.caller, options.managePower);
|
|
181
|
+
if (!options.hook) return fallback();
|
|
182
|
+
const decision = await options.hook({
|
|
183
|
+
action,
|
|
184
|
+
capability: options.capability,
|
|
185
|
+
record: options.record,
|
|
186
|
+
caller: { userId: options.caller.userId, organizationId: organizationOf(options.caller) },
|
|
187
|
+
env: options.env,
|
|
188
|
+
});
|
|
189
|
+
if (decision === "allow") return true;
|
|
190
|
+
if (decision === "deny") return false;
|
|
191
|
+
return fallback();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* What the caller may do with one record: nothing, read it, or change it.
|
|
196
|
+
*
|
|
197
|
+
* Read is decided first, and write is never asked about a record the caller cannot read — so a record
|
|
198
|
+
* the caller cannot see answers `none`, which a route turns into **not found**, never forbidden. Only a
|
|
199
|
+
* caller who may already read a record can learn it is not theirs to change.
|
|
200
|
+
*/
|
|
201
|
+
export async function accessLevel(options: AccessOptions): Promise<AccessLevel> {
|
|
202
|
+
if (!(await decide("read", options))) return "none";
|
|
203
|
+
return (await decide("write", options)) ? "write" : "read";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** What {@link keepReadable} needs. The rules already ran in SQL, so no power is consulted here. */
|
|
207
|
+
export interface KeepReadableOptions {
|
|
208
|
+
readonly capability: string;
|
|
209
|
+
readonly caller: AccessCaller;
|
|
210
|
+
readonly hook?: PermissionHook;
|
|
211
|
+
readonly env: Record<string, unknown>;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Ask the hook about every row on a page and drop the ones it denies.
|
|
216
|
+
*
|
|
217
|
+
* Only a deny changes anything: the rows arrived through {@link readablePredicate}, so the rules already
|
|
218
|
+
* admitted them, and an allow cannot add a row that was never on the page. A page can come back short;
|
|
219
|
+
* the capability's cursor is computed before this runs, so a short page still says there is more.
|
|
220
|
+
*/
|
|
221
|
+
export async function keepReadable<Row extends AccessRecord>(
|
|
222
|
+
rows: readonly Row[],
|
|
223
|
+
options: KeepReadableOptions,
|
|
224
|
+
): Promise<Row[]> {
|
|
225
|
+
const hook = options.hook;
|
|
226
|
+
if (!hook) return [...rows];
|
|
227
|
+
const kept: Row[] = [];
|
|
228
|
+
for (const row of rows) {
|
|
229
|
+
const decision = await hook({
|
|
230
|
+
action: "read",
|
|
231
|
+
capability: options.capability,
|
|
232
|
+
record: row,
|
|
233
|
+
caller: { userId: options.caller.userId, organizationId: organizationOf(options.caller) },
|
|
234
|
+
env: options.env,
|
|
235
|
+
});
|
|
236
|
+
if (decision !== "deny") kept.push(row);
|
|
237
|
+
}
|
|
238
|
+
return kept;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** What a list asks for beyond the rules. */
|
|
242
|
+
export interface ReadablePredicateOptions {
|
|
243
|
+
/** The capability's manage power — a holder lists every record in their organization. */
|
|
244
|
+
readonly managePower: string;
|
|
245
|
+
/** Add every public record. Off by default: nobody receives what they did not ask for. */
|
|
246
|
+
readonly includePublic?: boolean;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The rules, in SQL — the `where` a list runs, over a table carrying `owner_id`, `organization_id` and
|
|
251
|
+
* `visibility`.
|
|
252
|
+
*
|
|
253
|
+
* **A list returns the caller's own records and their organization's.** Their own is every record they
|
|
254
|
+
* own in the organization they act for (or in none, while none is in force). Their organization's is every
|
|
255
|
+
* record in it whose visibility admits members, and every record at all for a holder of the manage power.
|
|
256
|
+
* `includePublic` adds every public record, and nothing else does.
|
|
257
|
+
*
|
|
258
|
+
* `sql.ref` rather than a typed builder, so one predicate serves every capability's table; the
|
|
259
|
+
* `CamelCasePlugin` still snake-cases each reference. Values are bound parameters, never interpolated.
|
|
260
|
+
*/
|
|
261
|
+
export function readablePredicate(caller: AccessCaller, options: ReadablePredicateOptions): RawBuilder<SqlBool> {
|
|
262
|
+
const owner = sql.ref("ownerId");
|
|
263
|
+
const organization = sql.ref("organizationId");
|
|
264
|
+
const visibility = sql.ref("visibility");
|
|
265
|
+
const organizationId = organizationOf(caller);
|
|
266
|
+
const clauses: RawBuilder<SqlBool>[] = [];
|
|
267
|
+
|
|
268
|
+
if (caller.userId !== null) {
|
|
269
|
+
clauses.push(
|
|
270
|
+
organizationId === null
|
|
271
|
+
? sql<SqlBool>`(${owner} = ${caller.userId} and ${organization} is null)`
|
|
272
|
+
: sql<SqlBool>`(${owner} = ${caller.userId} and ${organization} = ${organizationId})`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (caller.userId !== null && organizationId !== null) {
|
|
276
|
+
clauses.push(
|
|
277
|
+
caller.acting?.roleAllows(options.managePower)
|
|
278
|
+
? sql<SqlBool>`(${organization} = ${organizationId})`
|
|
279
|
+
: sql<SqlBool>`(${organization} = ${organizationId} and ${visibility} in ('organization', 'public'))`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
if (options.includePublic) clauses.push(sql<SqlBool>`(${visibility} = 'public')`);
|
|
283
|
+
|
|
284
|
+
if (clauses.length === 0) return sql<SqlBool>`0 = 1`;
|
|
285
|
+
return sql<SqlBool>`(${sql.join(clauses, sql` or `)})`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The caller, from a request: the signed-in user and the organization they act for.
|
|
290
|
+
*
|
|
291
|
+
* An anonymous caller never resolves an organization — there is no session to resolve one from, and not
|
|
292
|
+
* asking keeps a public read from paying for a tenancy query it cannot use.
|
|
293
|
+
*/
|
|
294
|
+
export async function accessCaller(vars: Pick<PithyVars, "auth" | "actingOrganization">): Promise<AccessCaller> {
|
|
295
|
+
const userId = vars.auth?.userId ?? null;
|
|
296
|
+
if (userId === null) return { userId: null, acting: null };
|
|
297
|
+
return { userId, acting: await vars.actingOrganization() };
|
|
298
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The acting-organization seam: which organization a request acts for, and what the caller's role there
|
|
6
|
+
* allows.
|
|
7
|
+
*
|
|
8
|
+
* **Core defines it and one capability fills it.** `@pithy-sh/organization` knows memberships, roles and
|
|
9
|
+
* the session's selection; `@pithy-sh/media` and `@pithy-sh/storage` need the answer and must never
|
|
10
|
+
* import the capability that computes it. The split is the `AuthContext` split, for the same reason: a
|
|
11
|
+
* capability that imported its tenancy from another package would fail open the day that package was
|
|
12
|
+
* absent, and through this seam it fails closed instead. With no organization capability composed the
|
|
13
|
+
* resolver answers `null`, every record a request writes carries a null organization, and every rule
|
|
14
|
+
* that admits by organization admits nobody.
|
|
15
|
+
*
|
|
16
|
+
* **A resolver, not a value.** Resolving costs a D1 read — the session's selection, rejoined against a
|
|
17
|
+
* live membership — and most requests never ask. So the organization capability publishes a function and
|
|
18
|
+
* the first reader pays for the answer; {@link memoizeActing} makes sure no second reader pays again.
|
|
19
|
+
* Reading `c.var.auth` at call time rather than at publish time is also what makes it independent of
|
|
20
|
+
* composition order: a Worker that lists `organization()` ahead of `auth()` still resolves against the
|
|
21
|
+
* session auth set.
|
|
22
|
+
*
|
|
23
|
+
* **Two facts and no more.** The organization id is what a write records and what a read is scoped to;
|
|
24
|
+
* `roleAllows` is the one authorization question a capability outside tenancy may ask, so it asks it
|
|
25
|
+
* without learning the role's name or the catalog's shape. A power is a string here because the catalog
|
|
26
|
+
* is the adopter's and is not known when core compiles; a power nobody declared simply allows nothing.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** The organization a request acts for, and the one question a capability may ask about the caller's role. */
|
|
30
|
+
export interface ActingOrganization {
|
|
31
|
+
/** The organization this request acts for. What a write records and what a scoped read filters on. */
|
|
32
|
+
readonly organizationId: string;
|
|
33
|
+
/**
|
|
34
|
+
* Whether the caller's role in that organization holds a power — `media:manage`, `storage:manage`.
|
|
35
|
+
* Answered from the membership row read on this request, never from a session claim.
|
|
36
|
+
*/
|
|
37
|
+
roleAllows(power: string): boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** How a request learns its acting organization. `null` is a real answer: nothing is in force. */
|
|
41
|
+
export type ActingOrganizationResolver = () => Promise<ActingOrganization | null>;
|
|
42
|
+
|
|
43
|
+
/** The seam's default: no organization capability is composed, so nothing is ever in force. */
|
|
44
|
+
export const noActingOrganization: ActingOrganizationResolver = async () => null;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Wrap a resolver so it runs at most once per request.
|
|
48
|
+
*
|
|
49
|
+
* The promise is kept, not the value, so concurrent readers share one read and a refusal stays a refusal:
|
|
50
|
+
* a resolver that threw for an unrecognized role must not be retried by the next reader into a different
|
|
51
|
+
* answer within the same request.
|
|
52
|
+
*/
|
|
53
|
+
export function memoizeActing(resolve: ActingOrganizationResolver): ActingOrganizationResolver {
|
|
54
|
+
let answer: Promise<ActingOrganization | null> | undefined;
|
|
55
|
+
return () => {
|
|
56
|
+
answer ??= resolve();
|
|
57
|
+
return answer;
|
|
58
|
+
};
|
|
59
|
+
}
|