@fungi.computer/puffball 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +443 -2
  3. package/dist/app-api.d.ts +7 -0
  4. package/dist/app-api.js +61 -0
  5. package/dist/app-backend-binding-vocabulary.d.ts +299 -0
  6. package/dist/app-backend-binding-vocabulary.js +257 -0
  7. package/dist/app-backend-bindings.d.ts +21 -0
  8. package/dist/app-backend-bindings.js +47 -0
  9. package/dist/app-declaration.d.ts +46 -0
  10. package/dist/app-declaration.js +115 -0
  11. package/dist/app-event-facts.d.ts +40 -0
  12. package/dist/app-event-facts.js +28 -0
  13. package/dist/app-method-context.d.ts +109 -0
  14. package/dist/app-method-context.js +9 -0
  15. package/dist/app-method-runtime.d.ts +86 -0
  16. package/dist/app-method-runtime.js +122 -0
  17. package/dist/app-origin-paths.d.ts +6 -0
  18. package/dist/app-origin-paths.js +6 -0
  19. package/dist/app-vocabulary.d.ts +189 -0
  20. package/dist/app-vocabulary.js +103 -0
  21. package/dist/app.d.ts +379 -0
  22. package/dist/app.js +71 -0
  23. package/dist/artifact-dispositions.d.ts +115 -0
  24. package/dist/artifact-dispositions.js +234 -0
  25. package/dist/assets.d.ts +54 -0
  26. package/dist/assets.js +89 -0
  27. package/dist/backend.d.ts +41 -0
  28. package/dist/backend.js +29 -0
  29. package/dist/background-replay.d.ts +115 -0
  30. package/dist/background-replay.js +175 -0
  31. package/dist/buyer-references.d.ts +69 -0
  32. package/dist/buyer-references.js +154 -0
  33. package/dist/cloudflare-assets.d.ts +48 -0
  34. package/dist/cloudflare-assets.js +105 -0
  35. package/dist/cloudflare.d.ts +254 -0
  36. package/dist/cloudflare.js +1619 -0
  37. package/dist/effect.d.ts +73 -0
  38. package/dist/effect.js +325 -0
  39. package/dist/git-representation.d.ts +13 -0
  40. package/dist/git-representation.js +16 -0
  41. package/dist/inbox.d.ts +132 -0
  42. package/dist/inbox.js +81 -0
  43. package/dist/index.d.ts +31 -0
  44. package/dist/index.js +85 -0
  45. package/dist/internal/app-description.d.ts +14 -0
  46. package/dist/internal/app-description.js +98 -0
  47. package/dist/internal/app-release-artifact-reader.d.ts +26 -0
  48. package/dist/internal/app-release-artifact-reader.js +1 -0
  49. package/dist/internal/app-release-contract.d.ts +540 -0
  50. package/dist/internal/app-release-contract.js +169 -0
  51. package/dist/internal/app-release-schema.d.ts +1676 -0
  52. package/dist/internal/app-release-schema.js +386 -0
  53. package/dist/internal/app-release-store.d.ts +47 -0
  54. package/dist/internal/app-release-store.js +1 -0
  55. package/dist/internal/background-sync.d.ts +7 -0
  56. package/dist/internal/background-sync.js +10 -0
  57. package/dist/internal/deterministic-memory-app-release-store.d.ts +14 -0
  58. package/dist/internal/deterministic-memory-app-release-store.js +319 -0
  59. package/dist/internal/failure.d.ts +2 -0
  60. package/dist/internal/failure.js +9 -0
  61. package/dist/internal/immutable-release-material.d.ts +3 -0
  62. package/dist/internal/immutable-release-material.js +13 -0
  63. package/dist/internal/promise-app-release-store-adapter.d.ts +9 -0
  64. package/dist/internal/promise-app-release-store-adapter.js +65 -0
  65. package/dist/internal/promise-artifact-reader-adapter.d.ts +9 -0
  66. package/dist/internal/promise-artifact-reader-adapter.js +18 -0
  67. package/dist/internal/public-route-sql.d.ts +10 -0
  68. package/dist/internal/public-route-sql.js +239 -0
  69. package/dist/internal/public-website-authority.d.ts +338 -0
  70. package/dist/internal/public-website-authority.js +50 -0
  71. package/dist/internal/public-website-delivery.d.ts +59 -0
  72. package/dist/internal/public-website-delivery.js +232 -0
  73. package/dist/internal/public-website-fetch.d.ts +8 -0
  74. package/dist/internal/public-website-fetch.js +54 -0
  75. package/dist/internal/release-transition-material.d.ts +3 -0
  76. package/dist/internal/release-transition-material.js +11 -0
  77. package/dist/internal/verified-artifact-snapshot.d.ts +41 -0
  78. package/dist/internal/verified-artifact-snapshot.js +91 -0
  79. package/dist/public-route-moderation.d.ts +83 -0
  80. package/dist/public-route-moderation.js +236 -0
  81. package/dist/public-route-policy.d.ts +51 -0
  82. package/dist/public-route-policy.js +88 -0
  83. package/dist/public-routes.d.ts +34 -0
  84. package/dist/public-routes.js +136 -0
  85. package/dist/publisher.d.ts +61 -0
  86. package/dist/publisher.js +16 -0
  87. package/dist/safety-takedown.d.ts +56 -0
  88. package/dist/safety-takedown.js +146 -0
  89. package/dist/seller-closure-notice.d.ts +14 -0
  90. package/dist/seller-closure-notice.js +14 -0
  91. package/dist/seller-closure.d.ts +15 -0
  92. package/dist/seller-closure.js +31 -0
  93. package/dist/testing.d.ts +8 -0
  94. package/dist/testing.js +47 -0
  95. package/docs/api/README.md +26 -0
  96. package/docs/api/app-backend-binding-vocabulary.md +276 -0
  97. package/docs/api/app-backend-bindings.md +141 -0
  98. package/docs/api/app-event-facts.md +110 -0
  99. package/docs/api/app-method-context.md +166 -0
  100. package/docs/api/app-method-runtime.md +330 -0
  101. package/docs/api/backend.md +108 -0
  102. package/docs/api/background-replay.md +516 -0
  103. package/docs/api/cloudflare.md +1044 -0
  104. package/docs/api/effect.md +2429 -0
  105. package/docs/api/git-representation.md +56 -0
  106. package/docs/api/inbox.md +289 -0
  107. package/docs/api/index.md +2868 -0
  108. package/docs/api/publisher.md +147 -0
  109. package/docs/api/testing.md +31 -0
  110. package/package.json +158 -3
@@ -0,0 +1,115 @@
1
+ import * as z from "zod";
2
+ import type { Sha256Digest } from "./internal/app-release-contract.js";
3
+ import { type HostActor, type AppMethodCallContext } from "./app-method-runtime.js";
4
+ import type { AppApi } from "./app-vocabulary.js";
5
+ export { backgroundSyncAllowed } from "./internal/background-sync.js";
6
+ /** The caller's single lightweight grammar for a host-sealed enqueue origin. */
7
+ export declare const backgroundOriginSchema: import("zod/mini").ZodMiniObject<{
8
+ method: import("zod/mini").ZodMiniString<string>;
9
+ seal: import("zod/mini").ZodMiniString<string>;
10
+ }, z.core.$strict>;
11
+ /** Origin evidence, never a retained grant or credential. */
12
+ export type BackgroundOrigin = z.output<typeof backgroundOriginSchema>;
13
+ /** The closed result of checking a claimed job's origin in a fresh replay frame. */
14
+ export declare const claimedJobAdmissionSchema: z.ZodUnion<readonly [z.ZodObject<{
15
+ kind: z.ZodLiteral<"admitted">;
16
+ }, z.core.$strict>, z.ZodObject<{
17
+ kind: z.ZodLiteral<"refused">;
18
+ code: z.ZodLiteral<"access_changed">;
19
+ }, z.core.$strict>]>;
20
+ /** A claim either passes origin admission or refuses before binding effects. */
21
+ export type ClaimedJobAdmission = z.output<typeof claimedJobAdmissionSchema>;
22
+ /** Host-minted replay port; an App cannot declare this binding. */
23
+ export type BackgroundBinding = Readonly<{
24
+ /** Check the actual claimed job's origin once in this frame. */
25
+ admit: (origin: BackgroundOrigin) => Promise<ClaimedJobAdmission>;
26
+ }>;
27
+ /** Derive a non-extractable host signing key; no secret is sent to an App. */
28
+ export declare function backgroundOriginKey(secret: string): Promise<CryptoKey>;
29
+ /** Seal only host-verified Instance, member lane and foreground method attribution. */
30
+ export declare function sealBackgroundOrigin(key: CryptoKey, claims: Readonly<{
31
+ instanceId: string;
32
+ lane: string;
33
+ method: string;
34
+ }>): Promise<BackgroundOrigin>;
35
+ /** Verify origin evidence against this frame's fresh admission; guest input never throws. */
36
+ export declare function admitClaimedOrigin(key: CryptoKey, frame: Readonly<{
37
+ instanceId: string;
38
+ lane: string;
39
+ admitted: readonly string[];
40
+ }>, raw: unknown): Promise<ClaimedJobAdmission>;
41
+ /** A queue lane is an actor identity, never a credential or grant. */
42
+ export declare function actorLane(actor: HostActor): string;
43
+ /** Only ordinary member calls may enqueue background work; ancestry is host-held. */
44
+ export declare function mayDefer(context: AppMethodCallContext, ancestry: readonly string[]): boolean;
45
+ /** Host-only projection: replace incoming eligibility with the verified write's sealed origin. */
46
+ export declare function backgroundContext(context: AppMethodCallContext, ancestry: readonly string[], method: Readonly<{
47
+ name: string;
48
+ effect: AppApi["methods"][string]["effect"];
49
+ }>, origin: BackgroundOrigin | undefined): AppMethodCallContext;
50
+ /** App-side eligibility reads only the host-attested context; it never invents ancestry. */
51
+ export declare function mayEnqueueBackground(context: AppMethodCallContext): boolean;
52
+ /** Identity and observed revisions only. Unknown fields, including bearer material, refuse. */
53
+ export declare const backgroundWakeSchema: z.ZodObject<{
54
+ installationId: z.ZodString;
55
+ actor: z.ZodObject<{
56
+ kind: import("zod/mini").ZodMiniLiteral<"member">;
57
+ memberId: import("zod/mini").ZodMiniString<string>;
58
+ }, z.core.$strict>;
59
+ lane: z.ZodString;
60
+ originMethod: z.ZodString;
61
+ observed: z.ZodObject<{
62
+ authorityVersion: z.ZodNumber;
63
+ artifactDigest: z.ZodCustom<Sha256Digest, Sha256Digest>;
64
+ membershipVersion: z.ZodOptional<z.ZodNumber>;
65
+ }, z.core.$strict>;
66
+ epoch: z.ZodNumber;
67
+ }, z.core.$strict>;
68
+ /** A host wake cannot confer authority without fresh directory and method admission. */
69
+ export type BackgroundWake = z.output<typeof backgroundWakeSchema>;
70
+ /** A retained identity eligible for member-only replay. */
71
+ export type BackgroundMember = BackgroundWake["actor"];
72
+ /** The App can settle a refused lane without receiving any binding authority. */
73
+ export declare const backgroundRefusalCodeSchema: z.ZodEnum<{
74
+ access_changed: "access_changed";
75
+ installation_unavailable: "installation_unavailable";
76
+ }>;
77
+ /** Cleanup payload for one refused member lane; contains no actor or authority. */
78
+ export declare const backgroundSyncRefusalSchema: z.ZodObject<{
79
+ lane: z.ZodString;
80
+ code: z.ZodEnum<{
81
+ access_changed: "access_changed";
82
+ installation_unavailable: "installation_unavailable";
83
+ }>;
84
+ }, z.core.$strict>;
85
+ /** One available App's member-lane cleanup, delivered with zero binding grants. */
86
+ export type BackgroundSyncRefusal = z.output<typeof backgroundSyncRefusalSchema>;
87
+ /** Closed, durable refusal reasons for the affected member lane. */
88
+ export type BackgroundRefusalCode = z.output<typeof backgroundRefusalCodeSchema>;
89
+ /** Existing method admission outcomes interpreted by the replay owner. */
90
+ export type BackgroundAdmission = Readonly<{
91
+ kind: "admitted";
92
+ context: AppMethodCallContext;
93
+ }> | Readonly<{
94
+ kind: "refused";
95
+ code: "level_too_low" | "requires_not_granted" | "not_approved" | "denied" | "unavailable" | "stale_authority";
96
+ }>;
97
+ /** Directory resolution and fresh continuation admission remain host-owned. */
98
+ export type BackgroundReplayPorts<Principal> = Readonly<{
99
+ /** Resolve the retained member using the host's live directory, without bearer replay. */
100
+ resolve: (actor: BackgroundMember) => Promise<Principal | null>;
101
+ /** Admit backend.sync.method as this principal with fresh method-scoped grants. */
102
+ admit: (principal: Principal, wake: BackgroundWake) => Promise<BackgroundAdmission>;
103
+ }>;
104
+ /** A refused wake settles its lane; retry uses the existing Carrier backoff. */
105
+ export type BackgroundReplayDecision = Readonly<{
106
+ kind: "admitted";
107
+ context: AppMethodCallContext;
108
+ }> | Readonly<{
109
+ kind: "refused";
110
+ code: BackgroundRefusalCode;
111
+ }> | Readonly<{
112
+ kind: "retry";
113
+ }>;
114
+ /** Resolve then re-admit. Observed revisions are evidence, never equality pins. */
115
+ export declare function readmitBackground<Principal>(input: unknown, ports: BackgroundReplayPorts<Principal>): Promise<BackgroundReplayDecision>;
@@ -0,0 +1,175 @@
1
+ import * as z from "zod";
2
+ import * as Schema from "effect/Schema";
3
+ import * as Base64Url from "effect/encoding/Base64Url";
4
+ import { WatchdogQueuedJobSchema } from "@fungi.computer/watchdog";
5
+ import { sha256DigestSchema } from "./internal/app-release-schema.js";
6
+ import { hostActorSchema, appMethodCallerSchema, isAppApiMethodName, parseAppMethodCallContext, } from "./app-method-runtime.js";
7
+ export { backgroundSyncAllowed } from "./internal/background-sync.js";
8
+ /** The caller's single lightweight grammar for a host-sealed enqueue origin. */
9
+ export const backgroundOriginSchema = appMethodCallerSchema.shape.backgroundOrigin.def.innerType;
10
+ /** The closed result of checking a claimed job's origin in a fresh replay frame. */
11
+ export const claimedJobAdmissionSchema = z.union([
12
+ z.strictObject({ kind: z.literal("admitted") }),
13
+ z.strictObject({
14
+ kind: z.literal("refused"),
15
+ code: z.literal("access_changed"),
16
+ }),
17
+ ]);
18
+ const originBytes = (claims) => new TextEncoder().encode(JSON.stringify([
19
+ "fungi.background-origin/1",
20
+ claims.instanceId,
21
+ claims.lane,
22
+ claims.method,
23
+ ]));
24
+ /** Derive a non-extractable host signing key; no secret is sent to an App. */
25
+ export async function backgroundOriginKey(secret) {
26
+ const text = new TextEncoder();
27
+ const material = await crypto.subtle.importKey("raw", text.encode(secret), "HKDF", false, ["deriveKey"]);
28
+ return crypto.subtle.deriveKey({
29
+ name: "HKDF",
30
+ hash: "SHA-256",
31
+ salt: text.encode("fungi.app.background-origin"),
32
+ info: text.encode("v1"),
33
+ }, material, { name: "HMAC", hash: "SHA-256", length: 256 }, false, ["sign", "verify"]);
34
+ }
35
+ /** Seal only host-verified Instance, member lane and foreground method attribution. */
36
+ export async function sealBackgroundOrigin(key, claims) {
37
+ const signature = await crypto.subtle.sign("HMAC", key, originBytes(claims));
38
+ return backgroundOriginSchema.parse({
39
+ method: claims.method,
40
+ seal: Base64Url.encode(new Uint8Array(signature)),
41
+ });
42
+ }
43
+ /** Verify origin evidence against this frame's fresh admission; guest input never throws. */
44
+ export async function admitClaimedOrigin(key, frame, raw) {
45
+ const refused = {
46
+ kind: "refused",
47
+ code: "access_changed",
48
+ };
49
+ const parsed = backgroundOriginSchema.safeParse(raw);
50
+ if (!parsed.success)
51
+ return refused;
52
+ const decoded = Base64Url.decode(parsed.data.seal);
53
+ if (decoded._tag === "Failure" ||
54
+ Base64Url.encode(decoded.success) !== parsed.data.seal)
55
+ return refused;
56
+ try {
57
+ const valid = await crypto.subtle.verify("HMAC", key, new Uint8Array(decoded.success), originBytes({
58
+ instanceId: frame.instanceId,
59
+ lane: frame.lane,
60
+ method: parsed.data.method,
61
+ }));
62
+ return valid && frame.admitted.includes(parsed.data.method)
63
+ ? { kind: "admitted" }
64
+ : refused;
65
+ }
66
+ catch {
67
+ return refused;
68
+ }
69
+ }
70
+ /** A queue lane is an actor identity, never a credential or grant. */
71
+ export function actorLane(actor) {
72
+ switch (actor.kind) {
73
+ case "member":
74
+ return `member:${actor.memberId}`;
75
+ case "agent":
76
+ return `agent:${actor.agentId}`;
77
+ case "team-key":
78
+ return `team-key:${actor.keyId}`;
79
+ }
80
+ }
81
+ /** Only ordinary member calls may enqueue background work; ancestry is host-held. */
82
+ export function mayDefer(context, ancestry) {
83
+ return (context.actor.kind === "member" &&
84
+ context.workflow === undefined &&
85
+ ancestry.length === 0 &&
86
+ Schema.is(WatchdogQueuedJobSchema.fields.lane)(actorLane(context.actor)));
87
+ }
88
+ /** Host-only projection: replace incoming eligibility with the verified write's sealed origin. */
89
+ export function backgroundContext(context, ancestry, method, origin) {
90
+ const caller = { ...parseAppMethodCallContext(context, true) };
91
+ delete caller.backgroundLane;
92
+ delete caller.backgroundOrigin;
93
+ if (method.effect !== "write" || !mayDefer(caller, ancestry))
94
+ return caller;
95
+ const sealed = backgroundOriginSchema.parse(origin);
96
+ if (sealed.method !== method.name)
97
+ throw new Error("Background origin must match the verified foreground method");
98
+ return {
99
+ ...caller,
100
+ backgroundLane: actorLane(caller.actor),
101
+ backgroundOrigin: sealed,
102
+ };
103
+ }
104
+ /** App-side eligibility reads only the host-attested context; it never invents ancestry. */
105
+ export function mayEnqueueBackground(context) {
106
+ return (context.actor.kind === "member" &&
107
+ context.workflow === undefined &&
108
+ context.backgroundOrigin !== undefined &&
109
+ context.backgroundLane === actorLane(context.actor) &&
110
+ Schema.is(WatchdogQueuedJobSchema.fields.lane)(context.backgroundLane));
111
+ }
112
+ const member = z.strictObject(hostActorSchema.def.options[0].shape);
113
+ const revision = z.number().int().positive().max(Number.MAX_SAFE_INTEGER);
114
+ /** Identity and observed revisions only. Unknown fields, including bearer material, refuse. */
115
+ export const backgroundWakeSchema = z
116
+ .strictObject({
117
+ installationId: z.string().min(1).max(256),
118
+ actor: member,
119
+ lane: z
120
+ .string()
121
+ .refine((value) => Schema.is(WatchdogQueuedJobSchema.fields.lane)(value)),
122
+ originMethod: z.string().refine(isAppApiMethodName),
123
+ observed: z.strictObject({
124
+ authorityVersion: revision,
125
+ artifactDigest: z.custom((value) => Schema.is(sha256DigestSchema)(value)),
126
+ // Oyster has no member revision. The Hub supplies its real directory revision.
127
+ membershipVersion: revision.optional(),
128
+ }),
129
+ epoch: revision,
130
+ })
131
+ .refine((wake) => wake.lane === actorLane(wake.actor), {
132
+ message: "Wake lane must name its member",
133
+ });
134
+ /** The App can settle a refused lane without receiving any binding authority. */
135
+ export const backgroundRefusalCodeSchema = z.enum([
136
+ "access_changed",
137
+ "installation_unavailable",
138
+ ]);
139
+ /** Cleanup payload for one refused member lane; contains no actor or authority. */
140
+ export const backgroundSyncRefusalSchema = z.strictObject({
141
+ lane: z
142
+ .string()
143
+ .startsWith("member:")
144
+ .min(8)
145
+ .refine((value) => Schema.is(WatchdogQueuedJobSchema.fields.lane)(value)),
146
+ code: backgroundRefusalCodeSchema,
147
+ });
148
+ /** Resolve then re-admit. Observed revisions are evidence, never equality pins. */
149
+ export async function readmitBackground(input, ports) {
150
+ const wake = backgroundWakeSchema.parse(input);
151
+ const principal = await ports.resolve(wake.actor);
152
+ if (principal === null)
153
+ return { kind: "refused", code: "access_changed" };
154
+ const decision = await ports.admit(principal, wake);
155
+ if (decision.kind === "admitted") {
156
+ const context = { ...parseAppMethodCallContext(decision.context, true) };
157
+ // Fresh admission of a continuation does not grant another enqueue wake.
158
+ delete context.backgroundLane;
159
+ delete context.backgroundOrigin;
160
+ if (!mayDefer(context, []) || actorLane(context.actor) !== wake.lane)
161
+ return { kind: "refused", code: "access_changed" };
162
+ return { kind: "admitted", context };
163
+ }
164
+ switch (decision.code) {
165
+ case "stale_authority":
166
+ return { kind: "retry" };
167
+ case "unavailable":
168
+ return { kind: "refused", code: "installation_unavailable" };
169
+ case "level_too_low":
170
+ case "requires_not_granted":
171
+ case "not_approved":
172
+ case "denied":
173
+ return { kind: "refused", code: "access_changed" };
174
+ }
175
+ }
@@ -0,0 +1,69 @@
1
+ import * as Schema from "effect/Schema";
2
+ import type { AppReleaseD1Database } from "./cloudflare.js";
3
+ /** Positive installed-release custody. This record alone never grants access to a caller. */
4
+ export declare const buyerReleaseReferenceSchema: Schema.Struct<{
5
+ readonly buyerTeamId: Schema.String;
6
+ readonly installationId: Schema.String;
7
+ readonly appId: Schema.refine<import("./index.js").AppId, Schema.String>;
8
+ readonly releaseId: Schema.refine<import("./index.js").ReleaseId, Schema.String>;
9
+ readonly artifactDigest: Schema.refine<import("./index.js").Sha256Digest, Schema.String>;
10
+ readonly publicSurfaceId: Schema.refine<import("./index.js").PublicSurfaceId, Schema.String>;
11
+ readonly stableRoute: Schema.refine<import("./index.js").StableRoute, Schema.String>;
12
+ readonly acquisitionVersion: Schema.Number;
13
+ readonly acquisitionDecisionId: Schema.String;
14
+ readonly selectionSequence: Schema.Number;
15
+ }>;
16
+ export type BuyerReleaseReference = Schema.Schema.Type<typeof buyerReleaseReferenceSchema>;
17
+ /** No Surface FK: buyer references survive seller erasure and are the revocation recipient index. */
18
+ export declare const buyerReferenceSchemaSql: string;
19
+ /** Team admission owns the positive acquisition check before recording an install's retained basis. */
20
+ export declare function createD1BuyerReferences(database: AppReleaseD1Database): {
21
+ retain: (raw: BuyerReleaseReference) => Promise<boolean>;
22
+ read: (input: {
23
+ buyerTeamId: string;
24
+ installationId: string;
25
+ releaseId: string;
26
+ }) => Promise<{
27
+ readonly buyerTeamId: string;
28
+ readonly installationId: string;
29
+ readonly appId: import("./index.js").AppId;
30
+ readonly releaseId: import("./index.js").ReleaseId;
31
+ readonly artifactDigest: import("./index.js").Sha256Digest;
32
+ readonly publicSurfaceId: import("./index.js").PublicSurfaceId;
33
+ readonly stableRoute: import("./index.js").StableRoute;
34
+ readonly acquisitionVersion: number;
35
+ readonly acquisitionDecisionId: string;
36
+ readonly selectionSequence: number;
37
+ } | null>;
38
+ readRetained: (input: {
39
+ buyerTeamId: string;
40
+ installationId: string;
41
+ releaseId: string;
42
+ }) => Promise<BuyerReleaseReference | null>;
43
+ readPublication: (stableRoute: string, releaseId: string) => Promise<{
44
+ readonly buyerTeamId: string;
45
+ readonly installationId: string;
46
+ readonly appId: import("./index.js").AppId;
47
+ readonly releaseId: import("./index.js").ReleaseId;
48
+ readonly artifactDigest: import("./index.js").Sha256Digest;
49
+ readonly publicSurfaceId: import("./index.js").PublicSurfaceId;
50
+ readonly stableRoute: import("./index.js").StableRoute;
51
+ readonly acquisitionVersion: number;
52
+ readonly acquisitionDecisionId: string;
53
+ readonly selectionSequence: number;
54
+ } | null>;
55
+ readFrozenInstallation: (buyerTeamId: string, installationId: string, appId: string) => Promise<{
56
+ readonly buyerTeamId: string;
57
+ readonly installationId: string;
58
+ readonly appId: import("./index.js").AppId;
59
+ readonly releaseId: import("./index.js").ReleaseId;
60
+ readonly artifactDigest: import("./index.js").Sha256Digest;
61
+ readonly publicSurfaceId: import("./index.js").PublicSurfaceId;
62
+ readonly stableRoute: import("./index.js").StableRoute;
63
+ readonly acquisitionVersion: number;
64
+ readonly acquisitionDecisionId: string;
65
+ readonly selectionSequence: number;
66
+ } | null>;
67
+ releaseInstallation: (buyerTeamId: string, installationId: string) => Promise<void>;
68
+ affectedApps: (releaseId: string) => Promise<import("./index.js").AppId[]>;
69
+ };
@@ -0,0 +1,154 @@
1
+ import * as Schema from "effect/Schema";
2
+ import { appIdSchema, publicSurfaceIdSchema, releaseIdSchema, sha256DigestSchema, stableRouteSchema, } from "./internal/app-release-schema.js";
3
+ const identity = Schema.String.pipe(Schema.check(Schema.isMinLength(1)), Schema.check(Schema.isMaxLength(256)));
4
+ /** Positive installed-release custody. This record alone never grants access to a caller. */
5
+ export const buyerReleaseReferenceSchema = Schema.Struct({
6
+ buyerTeamId: identity,
7
+ installationId: identity,
8
+ appId: appIdSchema,
9
+ releaseId: releaseIdSchema,
10
+ artifactDigest: sha256DigestSchema,
11
+ publicSurfaceId: publicSurfaceIdSchema,
12
+ stableRoute: stableRouteSchema,
13
+ acquisitionVersion: Schema.Number.pipe(Schema.check(Schema.isInt()), Schema.check(Schema.isGreaterThan(0))),
14
+ acquisitionDecisionId: identity,
15
+ selectionSequence: Schema.Number.pipe(Schema.check(Schema.isInt()), Schema.check(Schema.isGreaterThan(0))),
16
+ });
17
+ /** No Surface FK: buyer references survive seller erasure and are the revocation recipient index. */
18
+ export const buyerReferenceSchemaSql = `CREATE TABLE app_release_buyer_references (
19
+ buyer_team_id TEXT NOT NULL, installation_id TEXT NOT NULL,
20
+ app_id TEXT NOT NULL REFERENCES app_release_apps(app_id),
21
+ release_id TEXT NOT NULL REFERENCES app_release_releases(release_id),
22
+ artifact_digest TEXT NOT NULL, public_surface_id TEXT NOT NULL, stable_route TEXT NOT NULL,
23
+ acquisition_version INTEGER NOT NULL CHECK(acquisition_version>0),
24
+ acquisition_decision_id TEXT NOT NULL, selection_sequence INTEGER NOT NULL CHECK(selection_sequence>0),
25
+ PRIMARY KEY(buyer_team_id,installation_id,release_id)
26
+ ) STRICT;`.replaceAll("\n", " ") +
27
+ "\n" +
28
+ `CREATE INDEX app_release_buyers_by_release ON app_release_buyer_references(release_id,buyer_team_id,installation_id);`;
29
+ const columns = `buyer_team_id AS buyerTeamId,installation_id AS installationId,app_id AS appId,
30
+ release_id AS releaseId,artifact_digest AS artifactDigest,public_surface_id AS publicSurfaceId,
31
+ stable_route AS stableRoute,acquisition_version AS acquisitionVersion,
32
+ acquisition_decision_id AS acquisitionDecisionId,selection_sequence AS selectionSequence`;
33
+ /** Team admission owns the positive acquisition check before recording an install's retained basis. */
34
+ export function createD1BuyerReferences(database) {
35
+ const read = async (input) => {
36
+ const row = await database
37
+ .prepare(`SELECT ${columns} FROM app_release_buyer_references
38
+ WHERE buyer_team_id=? AND installation_id=? AND release_id=?`)
39
+ .bind(input.buyerTeamId, input.installationId, input.releaseId)
40
+ .first();
41
+ return row === null
42
+ ? null
43
+ : Schema.decodeUnknownSync(buyerReleaseReferenceSchema)(row);
44
+ };
45
+ /** Retained acquisition authority is independent of current route custody, but never of verification or revocation. */
46
+ const readRetained = async (input) => {
47
+ const row = await database
48
+ .prepare(`SELECT ${columns} FROM app_release_buyer_references b
49
+ WHERE buyer_team_id=? AND installation_id=? AND release_id=?
50
+ AND EXISTS (SELECT 1 FROM app_release_releases r
51
+ JOIN app_release_release_verifications v ON v.release_id=r.release_id
52
+ WHERE r.release_id=b.release_id AND r.app_id=b.app_id
53
+ AND r.artifact_digest=b.artifact_digest AND v.artifact_digest=b.artifact_digest)
54
+ AND NOT EXISTS (SELECT 1 FROM app_release_transitions t
55
+ WHERE t.release_id=b.release_id AND t.operation_kind='revocation')`)
56
+ .bind(input.buyerTeamId, input.installationId, input.releaseId)
57
+ .first();
58
+ return row === null
59
+ ? null
60
+ : Schema.decodeUnknownSync(buyerReleaseReferenceSchema)(row);
61
+ };
62
+ const retain = async (raw) => {
63
+ const input = Schema.decodeUnknownSync(buyerReleaseReferenceSchema, {
64
+ onExcessProperty: "error",
65
+ })(raw);
66
+ await database
67
+ .prepare(`INSERT INTO app_release_buyer_references
68
+ (buyer_team_id,installation_id,app_id,release_id,artifact_digest,public_surface_id,stable_route,
69
+ acquisition_version,acquisition_decision_id,selection_sequence)
70
+ SELECT ?,?,?,?,?,?,?,?,?,? WHERE EXISTS (
71
+ SELECT 1 FROM app_release_route_claims c
72
+ JOIN app_release_public_surfaces s ON s.public_surface_id=c.public_surface_id
73
+ JOIN app_release_releases r ON r.app_id=s.app_id
74
+ JOIN app_release_release_verifications v ON v.release_id=r.release_id AND v.artifact_digest=r.artifact_digest
75
+ JOIN app_release_transitions t ON t.public_surface_id=s.public_surface_id AND t.release_id=r.release_id
76
+ WHERE c.state='held' AND c.public_surface_id=? AND c.stable_route=? AND s.app_id=?
77
+ AND r.release_id=? AND r.artifact_digest=? AND t.sequence=? AND t.operation_kind IN ('promotion','rollback')
78
+ AND NOT EXISTS (SELECT 1 FROM app_release_surface_retirements x WHERE x.public_surface_id=c.public_surface_id)
79
+ AND NOT EXISTS (SELECT 1 FROM app_release_safety_takedowns a WHERE a.app_id=s.app_id AND a.stable_route=s.stable_route)
80
+ AND NOT EXISTS (SELECT 1 FROM app_release_transitions revoked WHERE revoked.release_id=r.release_id AND revoked.operation_kind='revocation')
81
+ ) ON CONFLICT(buyer_team_id,installation_id,release_id) DO UPDATE SET
82
+ acquisition_version=MAX(acquisition_version,excluded.acquisition_version),
83
+ acquisition_decision_id=CASE WHEN excluded.acquisition_version>acquisition_version THEN excluded.acquisition_decision_id ELSE acquisition_decision_id END,
84
+ selection_sequence=MAX(selection_sequence,excluded.selection_sequence)
85
+ WHERE app_id=excluded.app_id AND artifact_digest=excluded.artifact_digest
86
+ AND public_surface_id=excluded.public_surface_id AND stable_route=excluded.stable_route`)
87
+ .bind(input.buyerTeamId, input.installationId, input.appId, input.releaseId, input.artifactDigest, input.publicSurfaceId, input.stableRoute, input.acquisitionVersion, input.acquisitionDecisionId, input.selectionSequence, input.publicSurfaceId, input.stableRoute, input.appId, input.releaseId, input.artifactDigest, input.selectionSequence)
88
+ .run();
89
+ const saved = await read(input);
90
+ return (saved !== null &&
91
+ saved.appId === input.appId &&
92
+ saved.artifactDigest === input.artifactDigest &&
93
+ saved.publicSurfaceId === input.publicSurfaceId &&
94
+ saved.stableRoute === input.stableRoute &&
95
+ saved.acquisitionVersion >= input.acquisitionVersion &&
96
+ (saved.acquisitionVersion > input.acquisitionVersion ||
97
+ saved.acquisitionDecisionId === input.acquisitionDecisionId) &&
98
+ saved.selectionSequence >= input.selectionSequence);
99
+ };
100
+ /** Formerly public immutable bytes remain deliverable while a buyer holds this exact publication. */
101
+ const readPublication = async (stableRoute, releaseId) => {
102
+ const row = await database
103
+ .prepare(`SELECT ${columns} FROM app_release_buyer_references
104
+ WHERE stable_route=? AND release_id=? ORDER BY buyer_team_id,installation_id LIMIT 1`)
105
+ .bind(stableRoute, releaseId)
106
+ .first();
107
+ if (row === null)
108
+ return null;
109
+ const reference = Schema.decodeUnknownSync(buyerReleaseReferenceSchema)(row);
110
+ return readRetained(reference);
111
+ };
112
+ /** Freeze following installs at their last admitted publication when that Surface loses custody. */
113
+ const readFrozenInstallation = async (buyerTeamId, installationId, appId) => {
114
+ const row = await database
115
+ .prepare(`SELECT ${columns} FROM app_release_buyer_references b
116
+ WHERE buyer_team_id=? AND installation_id=? AND app_id=?
117
+ AND NOT EXISTS (SELECT 1 FROM app_release_route_claims c
118
+ WHERE c.public_surface_id=b.public_surface_id AND c.state='held'
119
+ AND NOT EXISTS (SELECT 1 FROM app_release_surface_retirements x WHERE x.public_surface_id=c.public_surface_id))
120
+ ORDER BY selection_sequence DESC,release_id LIMIT 1`)
121
+ .bind(buyerTeamId, installationId, appId)
122
+ .first();
123
+ return row === null
124
+ ? null
125
+ : Schema.decodeUnknownSync(buyerReleaseReferenceSchema)(row);
126
+ };
127
+ const releaseInstallation = async (buyerTeamId, installationId) => {
128
+ await database
129
+ .prepare("DELETE FROM app_release_buyer_references WHERE buyer_team_id=? AND installation_id=?")
130
+ .bind(buyerTeamId, installationId)
131
+ .run();
132
+ };
133
+ const affectedApps = async (releaseId) => {
134
+ // Release identity has one App owner; the indexed existence read is bounded independently of buyer count.
135
+ const result = await database
136
+ .prepare(`SELECT DISTINCT app_id AS appId FROM app_release_buyer_references WHERE release_id=? LIMIT 2`)
137
+ .bind(releaseId)
138
+ .all();
139
+ const values = result.results.map((row) => Schema.decodeUnknownSync(Schema.Struct({ appId: appIdSchema }))(row)
140
+ .appId);
141
+ if (values.length > 1)
142
+ throw new Error("Buyer Release owner conflicts");
143
+ return values;
144
+ };
145
+ return {
146
+ retain,
147
+ read,
148
+ readRetained,
149
+ readPublication,
150
+ readFrozenInstallation,
151
+ releaseInstallation,
152
+ affectedApps,
153
+ };
154
+ }
@@ -0,0 +1,48 @@
1
+ import type { AppReleaseCatalog, ReleaseId, ResolvedApp, StableRoute } from "./index.js";
2
+ import { type AssetBuildPolicy, type BuildAsset } from "./assets.js";
3
+ /** Native R2 metadata: checksums are validated by R2, not custom metadata claims. */
4
+ export type AssetR2Object = {
5
+ readonly version: string;
6
+ readonly size: number;
7
+ readonly checksums: {
8
+ readonly sha256?: ArrayBuffer;
9
+ };
10
+ };
11
+ /** Native R2 operations required to verify and serve one asset build. */
12
+ export type AssetR2Bucket = {
13
+ /** Read immutable object metadata without loading its body. */
14
+ head(key: string): Promise<AssetR2Object | null>;
15
+ /** Read immutable object metadata and its streaming body. */
16
+ get(key: string): Promise<(AssetR2Object & {
17
+ readonly body: ReadableStream<Uint8Array>;
18
+ }) | null>;
19
+ };
20
+ /** A missing referenced object is an unavailable build, never an unknown-path 404. */
21
+ export declare class AssetBuildUnavailableError extends Error {
22
+ constructor();
23
+ }
24
+ export declare function matchesAssetObject(asset: BuildAsset, object: AssetR2Object | null): boolean;
25
+ /** Verify every reference with bounded sequential native metadata reads. */
26
+ export declare function verifyAssetBuildObjects(bytes: Uint8Array, bucket: Pick<AssetR2Bucket, "head">, policy: AssetBuildPolicy): Promise<void>;
27
+ /**
28
+ * Caller supplies its owner-selected Release on every request. Catalog resolves
29
+ * and rechecks eligibility; neither pathname nor child ASSETS selects authority.
30
+ */
31
+ export declare function readSelectedAssetBuild(options: {
32
+ /** App snapshot resolved by the caller at this delivery boundary. */
33
+ app: ResolvedApp;
34
+ /** Trusted host mount pathname; a handle-root shell supplies "/". */
35
+ base: string;
36
+ bucket: AssetR2Bucket;
37
+ policy: AssetBuildPolicy;
38
+ request: Request;
39
+ }): Promise<Response>;
40
+ /** Anonymous Surface delivery uses its current selection, never arbitrary unpublished IDs. */
41
+ export declare function readPublicAssetBuild(options: {
42
+ catalog: Pick<AppReleaseCatalog, "resolvePublicAssetBuildVersion">;
43
+ stableRoute: StableRoute;
44
+ expectedReleaseId: ReleaseId;
45
+ bucket: AssetR2Bucket;
46
+ policy: AssetBuildPolicy;
47
+ request: Request;
48
+ }): Promise<Response>;
@@ -0,0 +1,105 @@
1
+ import { handleAssetRequest, } from "@fungi.computer/cloudflare-worker-bundler/assets";
2
+ import { assetBuildMediaType, decodeAssetBuild, publicAssetBase, } from "./assets.js";
3
+ /** A missing referenced object is an unavailable build, never an unknown-path 404. */
4
+ export class AssetBuildUnavailableError extends Error {
5
+ constructor() {
6
+ super("Asset build unavailable");
7
+ this.name = "AssetBuildUnavailableError";
8
+ }
9
+ }
10
+ export function matchesAssetObject(asset, object) {
11
+ if (!object?.checksums.sha256 ||
12
+ object.version !== asset.version ||
13
+ object.size !== asset.byteLength)
14
+ return false;
15
+ const digest = Array.from(new Uint8Array(object.checksums.sha256), (byte) => byte.toString(16).padStart(2, "0")).join("");
16
+ return `sha256:${digest}` === asset.digest;
17
+ }
18
+ /** Verify every reference with bounded sequential native metadata reads. */
19
+ export async function verifyAssetBuildObjects(bytes, bucket, policy) {
20
+ const build = decodeAssetBuild(bytes, policy);
21
+ for (const asset of build.assets) {
22
+ if (!matchesAssetObject(asset, await bucket.head(asset.artifactRef)))
23
+ throw new AssetBuildUnavailableError();
24
+ }
25
+ }
26
+ /**
27
+ * Caller supplies its owner-selected Release on every request. Catalog resolves
28
+ * and rechecks eligibility; neither pathname nor child ASSETS selects authority.
29
+ */
30
+ export async function readSelectedAssetBuild(options) {
31
+ if (options.app.ui?.mediaType !== assetBuildMediaType)
32
+ throw new AssetBuildUnavailableError();
33
+ return serveAssetBuild(options.app.ui, {
34
+ ...options,
35
+ expectedBase: options.base,
36
+ allowRelativeMount: true,
37
+ });
38
+ }
39
+ /** Anonymous Surface delivery uses its current selection, never arbitrary unpublished IDs. */
40
+ export async function readPublicAssetBuild(options) {
41
+ const snapshot = await options.catalog.resolvePublicAssetBuildVersion(options.stableRoute, options.expectedReleaseId);
42
+ return serveAssetBuild(snapshot, {
43
+ ...options,
44
+ expectedBase: publicAssetBase(options.stableRoute, options.expectedReleaseId),
45
+ allowRelativeMount: true,
46
+ });
47
+ }
48
+ async function serveAssetBuild(snapshot, options) {
49
+ const build = decodeAssetBuild(snapshot.bytes.copy(), options.policy);
50
+ const base = build.base === "./" && options.allowRelativeMount
51
+ ? (options.expectedBase ?? "/")
52
+ : build.base;
53
+ if (options.expectedBase !== undefined && base !== options.expectedBase)
54
+ throw new AssetBuildUnavailableError();
55
+ const url = new URL(options.request.url);
56
+ if (url.pathname === base.slice(0, -1)) {
57
+ url.pathname = base;
58
+ return new Response(null, {
59
+ status: 307,
60
+ headers: {
61
+ Location: url.pathname + url.search,
62
+ "Cache-Control": "no-store",
63
+ },
64
+ });
65
+ }
66
+ if (!url.pathname.startsWith(base))
67
+ return new Response(null, { status: 404 });
68
+ url.pathname = "/" + url.pathname.slice(base.length);
69
+ const assetRequest = new Request(url, options.request);
70
+ const byPath = new Map(build.assets.map((asset) => [asset.path, asset]));
71
+ const manifest = new Map(build.assets.map((asset) => [
72
+ asset.path,
73
+ { contentType: asset.contentType, etag: asset.digest },
74
+ ]));
75
+ const response = await handleAssetRequest(assetRequest, manifest, {
76
+ async get(pathname) {
77
+ const asset = byPath.get(pathname);
78
+ if (!asset)
79
+ throw new AssetBuildUnavailableError();
80
+ const object = await options.bucket.get(asset.artifactRef);
81
+ // This metadata and body belong to the same native get result. A changed
82
+ // key cannot reuse old verification or leak replacement bytes.
83
+ if (!object || !matchesAssetObject(asset, object)) {
84
+ if (object)
85
+ await object.body.cancel();
86
+ throw new AssetBuildUnavailableError();
87
+ }
88
+ return object.body;
89
+ },
90
+ }, { not_found_handling: "single-page-application" });
91
+ if (response && response.status >= 300 && response.status < 400) {
92
+ const location = response.headers.get("Location");
93
+ if (location) {
94
+ const destination = new URL(location, url);
95
+ // Only maintained same-origin asset redirects are configured here.
96
+ if (destination.origin !== url.origin)
97
+ throw new AssetBuildUnavailableError();
98
+ destination.pathname = base + destination.pathname.slice(1);
99
+ const headers = new Headers(response.headers);
100
+ headers.set("Location", destination.pathname + destination.search + destination.hash);
101
+ return new Response(response.body, { status: response.status, headers });
102
+ }
103
+ }
104
+ return response ?? new Response("Not Found", { status: 404 });
105
+ }