@pithy-sh/core 0.9.0 → 0.11.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/asyncapi/document.d.ts +46 -0
- package/dist/asyncapi/document.d.ts.map +1 -0
- package/dist/asyncapi/document.js +115 -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/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 +105 -28
- package/dist/capability/capability.d.ts.map +1 -1
- package/dist/capability/composition.d.ts.map +1 -1
- package/dist/capability/composition.js +5 -1
- 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/settings.d.ts +96 -4
- package/dist/capability/settings.d.ts.map +1 -1
- package/dist/capability/settings.js +85 -4
- package/dist/cli/command.d.ts +364 -0
- package/dist/cli/command.d.ts.map +1 -0
- package/dist/cli/command.js +418 -0
- package/dist/cli/commandCatalog.generated.d.ts +2437 -0
- package/dist/cli/commandCatalog.generated.d.ts.map +1 -0
- package/dist/cli/commandCatalog.generated.js +2564 -0
- package/dist/cli/commandFacts.d.ts +164 -0
- package/dist/cli/commandFacts.d.ts.map +1 -0
- package/dist/cli/commandFacts.js +216 -0
- package/dist/controlPlane/capability.d.ts.map +1 -1
- package/dist/controlPlane/capability.js +12 -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 +47 -0
- package/dist/controlPlane/data/connection.d.ts.map +1 -1
- package/dist/controlPlane/data/connection.js +55 -1
- package/dist/controlPlane/error/errors.d.ts.map +1 -1
- package/dist/controlPlane/error/errors.js +4 -3
- 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 +28 -2
- package/dist/controlPlane/http/handlers.d.ts +60 -0
- package/dist/controlPlane/http/handlers.d.ts.map +1 -1
- package/dist/controlPlane/http/handlers.js +31 -2
- 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 +147 -0
- package/dist/controlPlane/http/responses.d.ts.map +1 -1
- package/dist/controlPlane/http/responses.js +61 -4
- package/dist/controlPlane/http/routes.d.ts +2 -1
- package/dist/controlPlane/http/routes.d.ts.map +1 -1
- package/dist/controlPlane/http/routes.js +50 -9
- 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 +13 -2
- package/dist/controlPlane/scope/scope.d.ts.map +1 -1
- package/dist/controlPlane/scope/scope.js +14 -3
- package/dist/createBackend.d.ts +7 -0
- package/dist/createBackend.d.ts.map +1 -1
- package/dist/createBackend.js +41 -6
- package/dist/createEntrypoint.d.ts.map +1 -1
- package/dist/createEntrypoint.js +5 -3
- 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/entitlement/require.js +1 -1
- package/dist/error/cause.d.ts +16 -116
- package/dist/error/cause.d.ts.map +1 -1
- package/dist/error/cause.js +19 -153
- package/dist/error/http.d.ts +184 -0
- package/dist/error/http.d.ts.map +1 -1
- package/dist/error/payload.d.ts +368 -0
- package/dist/error/payload.d.ts.map +1 -1
- package/dist/error/payload.js +126 -4
- package/dist/error/pithyError.d.ts +44 -0
- package/dist/error/pithyError.d.ts.map +1 -1
- package/dist/error/pithyError.js +56 -1
- package/dist/error/unresolved.d.ts +265 -0
- package/dist/error/unresolved.d.ts.map +1 -0
- package/dist/error/unresolved.js +599 -0
- package/dist/error/wire.d.ts +20 -0
- package/dist/error/wire.d.ts.map +1 -0
- package/dist/error/wire.js +19 -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/oidcWebhook.d.ts +3 -3
- package/dist/http/oidcWebhook.js +3 -3
- package/dist/http/routeContract.js +1 -1
- package/dist/http/routeSpec.d.ts +66 -0
- package/dist/http/routeSpec.d.ts.map +1 -0
- package/dist/http/routeSpec.js +40 -0
- package/dist/http/sameOrigin.d.ts +1 -1
- package/dist/http/sameOrigin.js +1 -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/http/signedWebhook.d.ts +3 -3
- package/dist/http/signedWebhook.js +3 -3
- package/dist/http/socket.d.ts +52 -0
- package/dist/http/socket.d.ts.map +1 -0
- package/dist/http/socket.js +2 -0
- package/dist/http/subprotocol.d.ts +29 -0
- package/dist/http/subprotocol.d.ts.map +1 -0
- package/dist/http/subprotocol.js +37 -0
- package/dist/http/validation.d.ts +40 -8
- package/dist/http/validation.d.ts.map +1 -1
- package/dist/http/validation.js +25 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/logger/record.d.ts +107 -0
- package/dist/logger/record.d.ts.map +1 -1
- package/dist/migrations/bookkeeping.d.ts +21 -0
- package/dist/migrations/bookkeeping.d.ts.map +1 -1
- package/dist/migrations/bookkeeping.js +32 -3
- 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/groups.d.ts +140 -0
- package/dist/migrations/groups.d.ts.map +1 -0
- package/dist/migrations/groups.js +191 -0
- package/dist/migrations/migrateStep.d.ts +13 -0
- package/dist/migrations/migrateStep.d.ts.map +1 -0
- package/dist/migrations/migrateStep.js +20 -0
- package/dist/migrations/owner.d.ts +7 -0
- package/dist/migrations/owner.d.ts.map +1 -1
- package/dist/migrations/owner.js +11 -7
- 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/retained.js +2 -2
- package/dist/migrations/runner.d.ts +76 -5
- package/dist/migrations/runner.d.ts.map +1 -1
- package/dist/migrations/runner.js +130 -19
- 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/domains.d.ts +17 -0
- package/dist/naming/domains.d.ts.map +1 -1
- package/dist/naming/domains.js +62 -4
- package/dist/naming/environment.d.ts.map +1 -1
- package/dist/naming/environment.js +2 -1
- 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/packageName.d.ts +17 -0
- package/dist/naming/packageName.d.ts.map +1 -0
- package/dist/naming/packageName.js +22 -0
- package/dist/naming/provisionScope.d.ts +6 -0
- package/dist/naming/provisionScope.d.ts.map +1 -1
- package/dist/naming/provisionScope.js +10 -4
- 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/openapi/document.d.ts +106 -0
- package/dist/openapi/document.d.ts.map +1 -0
- package/dist/openapi/document.js +314 -0
- package/dist/openapi/routeGroups.d.ts +8 -0
- package/dist/openapi/routeGroups.d.ts.map +1 -0
- package/dist/openapi/routeGroups.js +23 -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/text/comments.js +66 -4
- package/dist/text/literals.d.ts +43 -0
- package/dist/text/literals.d.ts.map +1 -0
- package/dist/text/literals.js +293 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.d.ts.map +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/dispatchRoute.d.ts.map +1 -1
- package/dist/workflow/dispatchRoute.js +12 -6
- package/dist/workflow/faults.d.ts +12 -1
- package/dist/workflow/faults.d.ts.map +1 -1
- package/dist/workflow/faults.js +24 -18
- package/dist/workflow/hostEnv.d.ts +1 -0
- package/dist/workflow/hostEnv.d.ts.map +1 -1
- package/dist/workflow/hostEnv.js +4 -2
- package/dist/workflow/loopback.d.ts.map +1 -1
- package/dist/workflow/loopback.js +2 -1
- package/package.json +4 -4
- 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/asyncapi/document.ts +164 -0
- package/src/audit/auditEvent.ts +4 -0
- package/src/capability/bindings.ts +159 -13
- package/src/capability/capability.ts +106 -29
- package/src/capability/composition.ts +2 -1
- package/src/capability/settings.ts +165 -4
- package/src/cli/command.ts +865 -0
- package/src/cli/commandCatalog.generated.ts +2723 -0
- package/src/cli/commandFacts.ts +220 -0
- package/src/controlPlane/capability.ts +25 -1
- package/src/controlPlane/context.ts +4 -0
- package/src/controlPlane/data/connection.ts +90 -0
- package/src/controlPlane/error/errors.ts +10 -3
- package/src/controlPlane/http/cors.ts +4 -4
- package/src/controlPlane/http/guard.ts +35 -2
- package/src/controlPlane/http/handlers.ts +41 -0
- package/src/controlPlane/http/readiness.ts +50 -0
- package/src/controlPlane/http/responses.ts +140 -2
- package/src/controlPlane/http/routes.ts +82 -10
- 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 +14 -2
- package/src/createBackend.ts +59 -8
- package/src/createEntrypoint.ts +9 -3
- package/src/entitlement/allowance.ts +85 -0
- package/src/entitlement/entitlement.ts +38 -0
- package/src/entitlement/meter.ts +69 -0
- package/src/entitlement/require.ts +1 -1
- package/src/error/cause.ts +18 -181
- package/src/error/payload.ts +270 -4
- package/src/error/pithyError.ts +76 -0
- package/src/error/unresolved.ts +853 -0
- package/src/error/wire.ts +40 -0
- package/src/http/authContext.ts +13 -1
- package/src/http/oidcWebhook.ts +3 -3
- package/src/http/routeContract.ts +1 -1
- package/src/http/routeSpec.ts +81 -0
- package/src/http/sameOrigin.ts +1 -1
- package/src/http/scopeGates.ts +34 -0
- package/src/http/signedWebhook.ts +3 -3
- package/src/http/socket.ts +55 -0
- package/src/http/subprotocol.ts +47 -0
- package/src/http/validation.ts +50 -8
- package/src/index.ts +1 -0
- package/src/migrations/bookkeeping.ts +39 -2
- package/src/migrations/capabilitySets.ts +87 -0
- package/src/migrations/groups.ts +295 -0
- package/src/migrations/migrateStep.ts +19 -0
- package/src/migrations/owner.ts +10 -6
- package/src/migrations/registry.ts +46 -2
- package/src/migrations/retained.ts +2 -2
- package/src/migrations/runner.ts +214 -22
- package/src/migrations/state.ts +94 -0
- package/src/naming/domains.ts +75 -16
- package/src/naming/environment.ts +2 -1
- package/src/naming/feature.ts +46 -7
- package/src/naming/limits.ts +35 -3
- package/src/naming/packageName.ts +20 -0
- package/src/naming/provisionScope.ts +21 -2
- package/src/naming/resourceNames.ts +52 -5
- package/src/openapi/document.ts +412 -0
- package/src/openapi/routeGroups.ts +25 -0
- package/src/seed/writeD1.ts +13 -4
- package/src/seed/writeKv.ts +6 -2
- package/src/text/comments.ts +56 -4
- package/src/text/literals.ts +294 -0
- package/src/version.generated.ts +1 -1
- package/src/worker/serviceLoopback.ts +105 -0
- package/src/workflow/dispatchRoute.ts +13 -6
- package/src/workflow/faults.ts +56 -27
- package/src/workflow/hostEnv.ts +9 -1
- package/src/workflow/loopback.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pithy-sh/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"kysely-d1": "^0.4.0"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
|
-
"@cloudflare/vitest-plugin": "^1.
|
|
42
|
+
"@cloudflare/vitest-plugin": "^1.4.2",
|
|
43
43
|
"@pithy-sh/tsconfig": "workspace:*",
|
|
44
44
|
"@vitest/coverage-v8": "^4.1.0",
|
|
45
45
|
"hono": "^4.13.2",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"tsdown": "^0.23.0",
|
|
48
48
|
"typescript": "^7.0.2",
|
|
49
49
|
"vitest": "^4.1.0",
|
|
50
|
-
"wrangler": "^4.
|
|
50
|
+
"wrangler": "^4.151.0",
|
|
51
51
|
"zod": "^4.4.0"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
type CapabilitySettings,
|
|
6
|
+
SettingsAccountIncomplete,
|
|
7
|
+
type SettingsFinding,
|
|
8
|
+
type UnansweredEnvironment,
|
|
9
|
+
} from "../capability/settings";
|
|
10
|
+
import { outsidePolicy, type VisibilityPolicy } from "./visibility";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `pithy doctor`'s check for records a narrowed visibility policy no longer allows.
|
|
14
|
+
*
|
|
15
|
+
* **Narrowing `allowed` never rewrites a record.** Making a public file private breaks every link already
|
|
16
|
+
* published — and for media each one is a Cloudflare call before its bytes are signed — so the kit does
|
|
17
|
+
* not do it behind anyone's back. A write that would create a record outside the policy is refused; the
|
|
18
|
+
* records already outside it are named, here, per deployed environment, and moving them is the adopter's
|
|
19
|
+
* explicit act.
|
|
20
|
+
*
|
|
21
|
+
* The local tier says nothing: which records exist is a fact about a deployed database, which only the
|
|
22
|
+
* account tier can read. An unprovisioned environment, or an account that cannot be reached, is unchecked
|
|
23
|
+
* rather than clean — and only that environment is: the others are still asked, and what they hold is still
|
|
24
|
+
* named ({@link SettingsAccountIncomplete}).
|
|
25
|
+
*/
|
|
26
|
+
export interface RecordsOutsidePolicyOptions {
|
|
27
|
+
/** The capability, for the finding's wording — `media`, `storage`. */
|
|
28
|
+
readonly capability: string;
|
|
29
|
+
/** What one record is called, for the finding's wording — `media record`, `file`. */
|
|
30
|
+
readonly noun: string;
|
|
31
|
+
/** The table the records live in, as named in SQL. */
|
|
32
|
+
readonly table: string;
|
|
33
|
+
/** The resolved policy this Worker composes. */
|
|
34
|
+
readonly policy: VisibilityPolicy;
|
|
35
|
+
/** How to move one record to an allowed level — the route or the call. */
|
|
36
|
+
readonly remedy: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The settings check: every record outside the policy, named, per deployed environment. */
|
|
40
|
+
export function recordsOutsidePolicy(options: RecordsOutsidePolicyOptions): CapabilitySettings {
|
|
41
|
+
const levels = outsidePolicy(options.policy);
|
|
42
|
+
return {
|
|
43
|
+
local: () => [],
|
|
44
|
+
async account(context) {
|
|
45
|
+
if (levels.length === 0) return [];
|
|
46
|
+
const findings: SettingsFinding[] = [];
|
|
47
|
+
const unanswered: UnansweredEnvironment[] = [];
|
|
48
|
+
for (const environment of context.environments) {
|
|
49
|
+
if (environment.name === "dev") continue;
|
|
50
|
+
let ids: readonly string[];
|
|
51
|
+
try {
|
|
52
|
+
ids = await context.account.appRows({
|
|
53
|
+
environment: environment.name,
|
|
54
|
+
workerDirectory: context.workerDirectory,
|
|
55
|
+
table: options.table,
|
|
56
|
+
column: "visibility",
|
|
57
|
+
values: levels,
|
|
58
|
+
});
|
|
59
|
+
} catch (cause) {
|
|
60
|
+
// Staging stood up before prod is the ordinary bring-up. Prod is unchecked; staging still answers.
|
|
61
|
+
unanswered.push({ environment: environment.name, cause });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (ids.length === 0) continue;
|
|
65
|
+
const plural = ids.length === 1 ? options.noun : `${options.noun}s`;
|
|
66
|
+
findings.push({
|
|
67
|
+
setting: `${options.capability} visibility.allowed`,
|
|
68
|
+
environment: environment.name,
|
|
69
|
+
problem: `${ids.length} ${plural} ${ids.length === 1 ? "is" : "are"} ${levels.join(" or ")}, which visibility.allowed (${options.policy.allowed.join(", ")}) no longer allows: ${ids.join(", ")}.`,
|
|
70
|
+
action: `Nothing is rewritten for you. ${options.remedy}, or widen visibility.allowed in ${context.workerDirectory}'s pithy.config.ts.`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (unanswered.length > 0) throw new SettingsAccountIncomplete(findings, unanswered);
|
|
74
|
+
return findings;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { type Kysely, type RawBuilder, sql } from "kysely";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What an account is holding — records and bytes, per owner and per organization — for the control plane.
|
|
9
|
+
*
|
|
10
|
+
* ## A per-owner figure is always an organization's figure
|
|
11
|
+
*
|
|
12
|
+
* **A report run by an organization for user X filters by that organization's id**, because the
|
|
13
|
+
* organization must never learn that the same person holds an account in another one. The number alone
|
|
14
|
+
* leaks it: a per-user total summed across organizations discloses that the user exists elsewhere, with no
|
|
15
|
+
* name attached and no row shown — the figure simply does not reconcile with what the organization can
|
|
16
|
+
* see. So an unscoped per-owner read is not a convenience this offers. It is a read that does not exist:
|
|
17
|
+
* {@link UsageQuery} refuses `groupBy: "owner"` and an `ownerId` filter unless an organization is named,
|
|
18
|
+
* and every per-owner row is keyed by the organization it was counted in.
|
|
19
|
+
*
|
|
20
|
+
* Duplicate detection is the same disclosure in different clothes — *does anybody hold this file* against
|
|
21
|
+
* *how much does this person hold* — and both are closed by the same column. Neither consults an
|
|
22
|
+
* adopter's permission hook: a hook that widened a per-subject total would reopen exactly this.
|
|
23
|
+
*
|
|
24
|
+
* ## The organization is the column, not a join
|
|
25
|
+
*
|
|
26
|
+
* Counted by the `organizationId` written on each record, which is the fact at the time of the write. A
|
|
27
|
+
* join through current membership would re-attribute last quarter's bytes the moment somebody changed
|
|
28
|
+
* organization. Only stored records count — a pending upload holds nothing yet, and a failed one never
|
|
29
|
+
* will.
|
|
30
|
+
*
|
|
31
|
+
* ## Indexed
|
|
32
|
+
*
|
|
33
|
+
* The tables carry `(organizationId, createdAt)` and `(ownerId, createdAt)`, so an organization's slice
|
|
34
|
+
* over a window is one index range and a grouping by organization walks the index rather than sorting.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** The sentinel that names records written with no organization in force — a query string cannot say null. */
|
|
38
|
+
export const NO_ORGANIZATION = "none";
|
|
39
|
+
|
|
40
|
+
const Id = z.string().min(1).max(128);
|
|
41
|
+
|
|
42
|
+
/** What a usage read asks. */
|
|
43
|
+
export const UsageQuery = z
|
|
44
|
+
.object({
|
|
45
|
+
groupBy: z
|
|
46
|
+
.enum(["owner", "organization"])
|
|
47
|
+
.describe(
|
|
48
|
+
"`organization` is one row per organization; `owner` is one row per owner within the organization named, never across organizations.",
|
|
49
|
+
),
|
|
50
|
+
organizationId: Id.optional().describe(
|
|
51
|
+
"Restrict to one organization. Required with `groupBy=owner` and with `ownerId`, because a per-owner figure is only ever an organization's. `none` is the records written with no organization in force.",
|
|
52
|
+
),
|
|
53
|
+
ownerId: Id.optional().describe("Restrict to one owner, within the organization named."),
|
|
54
|
+
since: z.iso.datetime().optional().describe("Count records created at or after this instant, ISO-8601."),
|
|
55
|
+
until: z.iso.datetime().optional().describe("Count records created before this instant, ISO-8601."),
|
|
56
|
+
limit: z.coerce.number().int().min(1).max(500).optional().describe("The most rows to return. 100 by default."),
|
|
57
|
+
})
|
|
58
|
+
.describe("A request for records and bytes held, grouped by owner or by organization.")
|
|
59
|
+
.check((ctx) => {
|
|
60
|
+
if (ctx.value.organizationId !== undefined) return;
|
|
61
|
+
if (ctx.value.groupBy !== "owner" && ctx.value.ownerId === undefined) return;
|
|
62
|
+
ctx.issues.push({
|
|
63
|
+
code: "custom",
|
|
64
|
+
input: ctx.value,
|
|
65
|
+
path: ["organizationId"],
|
|
66
|
+
message: `A per-owner read names its organization. Pass organizationId, or \`${NO_ORGANIZATION}\` for records written with none.`,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
export type UsageQuery = z.output<typeof UsageQuery>;
|
|
70
|
+
|
|
71
|
+
/** One row of a usage read. */
|
|
72
|
+
export const UsageRow = z
|
|
73
|
+
.object({
|
|
74
|
+
organizationId: z
|
|
75
|
+
.string()
|
|
76
|
+
.nullable()
|
|
77
|
+
.describe("The organization these records were written under. Null for records written with none."),
|
|
78
|
+
ownerId: z
|
|
79
|
+
.string()
|
|
80
|
+
.nullable()
|
|
81
|
+
.optional()
|
|
82
|
+
.describe("The owner, on a per-owner read. Null for system-owned records. Absent on a per-organization read."),
|
|
83
|
+
records: z.number().int().nonnegative().describe("How many stored records."),
|
|
84
|
+
bytes: z.number().int().nonnegative().describe("Their total size in bytes. A record of unknown size counts zero."),
|
|
85
|
+
})
|
|
86
|
+
.describe("Records and bytes held by one organization, or by one owner within one organization.");
|
|
87
|
+
export type UsageRow = z.infer<typeof UsageRow>;
|
|
88
|
+
|
|
89
|
+
/** What a usage read answers. */
|
|
90
|
+
export const UsageResponse = z
|
|
91
|
+
.object({
|
|
92
|
+
rows: z.array(UsageRow).describe("Largest first. Capped at the requested limit."),
|
|
93
|
+
truncated: z.boolean().describe("Whether more rows matched than were returned."),
|
|
94
|
+
})
|
|
95
|
+
.describe("Records and bytes held, grouped as asked.");
|
|
96
|
+
export type UsageResponse = z.infer<typeof UsageResponse>;
|
|
97
|
+
|
|
98
|
+
/** The default page of a usage read. */
|
|
99
|
+
const DEFAULT_LIMIT = 100;
|
|
100
|
+
|
|
101
|
+
/** Rows as SQLite returns them, before the counts are made numbers. */
|
|
102
|
+
export interface RawUsageRow {
|
|
103
|
+
organizationId: string | null;
|
|
104
|
+
ownerId?: string | null;
|
|
105
|
+
records: number | string;
|
|
106
|
+
bytes: number | string | null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The statement a usage read runs, over one record table — `pithyMediaAssets`, `pithyStorageObjects` —
|
|
111
|
+
* carrying `ownerId`, `organizationId`, `size`, `status` and `createdAt`. Exported so a test can ask SQLite
|
|
112
|
+
* how it plans the very statement {@link usageRollup} executes. Every value is a bound parameter.
|
|
113
|
+
*/
|
|
114
|
+
export function usageStatement(table: string, query: UsageQuery): RawBuilder<RawUsageRow> {
|
|
115
|
+
const limit = query.limit ?? DEFAULT_LIMIT;
|
|
116
|
+
const organization = sql.ref("organizationId");
|
|
117
|
+
const owner = sql.ref("ownerId");
|
|
118
|
+
const filters = [sql`${sql.ref("status")} = 'stored'`];
|
|
119
|
+
if (query.organizationId === NO_ORGANIZATION) filters.push(sql`${organization} is null`);
|
|
120
|
+
else if (query.organizationId !== undefined) filters.push(sql`${organization} = ${query.organizationId}`);
|
|
121
|
+
if (query.ownerId !== undefined) filters.push(sql`${owner} = ${query.ownerId}`);
|
|
122
|
+
if (query.since !== undefined) filters.push(sql`${sql.ref("createdAt")} >= ${Date.parse(query.since)}`);
|
|
123
|
+
if (query.until !== undefined) filters.push(sql`${sql.ref("createdAt")} < ${Date.parse(query.until)}`);
|
|
124
|
+
|
|
125
|
+
const keys = query.groupBy === "owner" ? sql`${organization}, ${owner}` : sql`${organization}`;
|
|
126
|
+
return sql<RawUsageRow>`select ${keys}, count(*) as records, coalesce(sum(${sql.ref("size")}), 0) as bytes from ${sql.table(table)} where ${sql.join(filters, sql` and `)} group by ${keys} order by bytes desc limit ${limit + 1}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Read usage off one record table through any Kysely over D1 with the `CamelCasePlugin`. */
|
|
130
|
+
export async function usageRollup(db: Kysely<unknown>, table: string, query: UsageQuery): Promise<UsageResponse> {
|
|
131
|
+
const limit = query.limit ?? DEFAULT_LIMIT;
|
|
132
|
+
const result = await usageStatement(table, query).execute(db);
|
|
133
|
+
const byOwner = query.groupBy === "owner";
|
|
134
|
+
const rows = result.rows.slice(0, limit).map((row) => ({
|
|
135
|
+
organizationId: row.organizationId ?? null,
|
|
136
|
+
...(byOwner ? { ownerId: row.ownerId ?? null } : {}),
|
|
137
|
+
records: Number(row.records),
|
|
138
|
+
bytes: Number(row.bytes ?? 0),
|
|
139
|
+
}));
|
|
140
|
+
return { rows, truncated: result.rows.length > limit };
|
|
141
|
+
}
|