@supacloud/cli 0.99.0 → 0.101.2
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/index.js
CHANGED
|
@@ -3177,23 +3177,22 @@ function InstanceKeys(value) {
|
|
|
3177
3177
|
function IsIEEE754(value) {
|
|
3178
3178
|
return typeof value === "number";
|
|
3179
3179
|
}
|
|
3180
|
-
var ByteMarker
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
})(ByteMarker || (ByteMarker = {}));
|
|
3180
|
+
var ByteMarker = {
|
|
3181
|
+
Array: 0,
|
|
3182
|
+
BigInt: 1,
|
|
3183
|
+
Boolean: 2,
|
|
3184
|
+
Date: 3,
|
|
3185
|
+
Constructor: 4,
|
|
3186
|
+
Function: 5,
|
|
3187
|
+
Null: 6,
|
|
3188
|
+
Number: 7,
|
|
3189
|
+
Object: 8,
|
|
3190
|
+
RegExp: 9,
|
|
3191
|
+
String: 10,
|
|
3192
|
+
Symbol: 11,
|
|
3193
|
+
TypeArray: 12,
|
|
3194
|
+
Undefined: 13
|
|
3195
|
+
};
|
|
3197
3196
|
var Accumulator = BigInt("14695981039346656037");
|
|
3198
3197
|
var [Prime, Size] = [BigInt("1099511628211"), BigInt("18446744073709551616")];
|
|
3199
3198
|
var Bytes = Array.from({ length: 256 }).map((_, i) => BigInt(i));
|
|
@@ -15196,7 +15195,7 @@ function scaffoldFiles(framework, slug) {
|
|
|
15196
15195
|
"package.json": JSON.stringify({
|
|
15197
15196
|
private: true,
|
|
15198
15197
|
type: "module",
|
|
15199
|
-
dependencies: { elysia: "2.0.0-beta.
|
|
15198
|
+
dependencies: { elysia: "2.0.0-beta.32" }
|
|
15200
15199
|
}, null, 2) + `
|
|
15201
15200
|
`,
|
|
15202
15201
|
"index.ts": `import { Elysia } from "elysia";
|
|
@@ -21389,34 +21388,34 @@ var starter_metadata_default = {
|
|
|
21389
21388
|
generatedBy: "supacloud/starter-metadata-v1",
|
|
21390
21389
|
packages: {
|
|
21391
21390
|
compiler: {
|
|
21392
|
-
version: "0.46.
|
|
21391
|
+
version: "0.46.4"
|
|
21393
21392
|
},
|
|
21394
21393
|
app: {
|
|
21395
|
-
version: "0.27.
|
|
21394
|
+
version: "0.27.1",
|
|
21396
21395
|
dependencies: {
|
|
21397
21396
|
rxjs: "7.8.2"
|
|
21398
21397
|
}
|
|
21399
21398
|
},
|
|
21400
21399
|
elysia: {
|
|
21401
|
-
version: "0.
|
|
21400
|
+
version: "0.31.1"
|
|
21402
21401
|
},
|
|
21403
21402
|
commands: {
|
|
21404
|
-
version: "0.9.
|
|
21403
|
+
version: "0.9.3"
|
|
21405
21404
|
},
|
|
21406
21405
|
contracts: {
|
|
21407
|
-
version: "0.
|
|
21406
|
+
version: "0.10.0"
|
|
21408
21407
|
},
|
|
21409
21408
|
db: {
|
|
21410
|
-
version: "0.17.
|
|
21409
|
+
version: "0.17.1",
|
|
21411
21410
|
peerDependencies: {
|
|
21412
|
-
"drizzle-orm": "1.0.0-rc.5-
|
|
21411
|
+
"drizzle-orm": "1.0.0-rc.5-5935859"
|
|
21413
21412
|
},
|
|
21414
21413
|
devDependencies: {
|
|
21415
|
-
"drizzle-kit": "1.0.0-rc.5-
|
|
21414
|
+
"drizzle-kit": "1.0.0-rc.5-5935859"
|
|
21416
21415
|
}
|
|
21417
21416
|
},
|
|
21418
21417
|
sdk: {
|
|
21419
|
-
version: "0.
|
|
21418
|
+
version: "0.43.0",
|
|
21420
21419
|
peerDependencies: {
|
|
21421
21420
|
"@supabase/supabase-js": "^2.117.3"
|
|
21422
21421
|
}
|
|
@@ -21759,8 +21758,8 @@ function baseFiles(name, sdkDependencies) {
|
|
|
21759
21758
|
dependencies: {
|
|
21760
21759
|
"@supacloud/app": `^${appMetadata.version}`,
|
|
21761
21760
|
"@supacloud/elysia": `^${elysiaMetadata.version}`,
|
|
21762
|
-
effect: "4.0.
|
|
21763
|
-
elysia: "2.0.0-beta.
|
|
21761
|
+
effect: "4.0.5",
|
|
21762
|
+
elysia: "2.0.0-beta.32",
|
|
21764
21763
|
...sdkDependencies ?? { rxjs: appMetadata.dependencies.rxjs }
|
|
21765
21764
|
},
|
|
21766
21765
|
devDependencies: {
|
|
@@ -22223,6 +22222,8 @@ export type ReviewAttachmentInput = Static<typeof AttachmentInput>;
|
|
|
22223
22222
|
export type ReviewAttachmentResult = Static<typeof AttachmentResult>;
|
|
22224
22223
|
|
|
22225
22224
|
export interface ReviewAttachmentStore {
|
|
22225
|
+
// Reauthorize before returning a committed result; null means execution is still needed.
|
|
22226
|
+
readResult(input: ReviewAttachmentInput): Promise<ReviewAttachmentResult | null>;
|
|
22226
22227
|
// Resolve the immutable artifact under the worker's own authorization and current review revision.
|
|
22227
22228
|
readAttachment(input: ReviewAttachmentInput): Promise<{ body: Uint8Array; sha256: string }>;
|
|
22228
22229
|
// Revalidate revision/ownership and persist idempotently in a transaction; return the durable result.
|
|
@@ -22238,6 +22239,8 @@ export class VerifyReviewAttachment {
|
|
|
22238
22239
|
constructor(@Inject(DB_CLIENT) private readonly store: ReviewAttachmentStore) {}
|
|
22239
22240
|
|
|
22240
22241
|
async run(input: ReviewAttachmentInput): Promise<ReviewAttachmentResult> {
|
|
22242
|
+
const committed = await this.store.readResult(input);
|
|
22243
|
+
if (committed !== null) return committed;
|
|
22241
22244
|
const source = await this.store.readAttachment(input);
|
|
22242
22245
|
if (source.body.byteLength < 1 || source.body.byteLength > 1048576) {
|
|
22243
22246
|
throw new ApplicationError("Attachment size is outside the supported range", { code: "ATTACHMENT_SIZE_INVALID" });
|
|
@@ -22262,6 +22265,7 @@ const sha256 = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", b
|
|
|
22262
22265
|
test("attachment job awaits the authorized reader and the committed result", async () => {
|
|
22263
22266
|
let recorded: ReviewAttachmentResult | undefined;
|
|
22264
22267
|
const job = new VerifyReviewAttachment({
|
|
22268
|
+
async readResult() { return null; },
|
|
22265
22269
|
async readAttachment() { return { body, sha256 }; },
|
|
22266
22270
|
async recordAttachment(_input, result) { await Promise.resolve(); recorded = result; return result; },
|
|
22267
22271
|
});
|
|
@@ -22278,16 +22282,33 @@ test("missing authorization, corrupted bytes and persistence failures are not su
|
|
|
22278
22282
|
async () => ({ body: new Uint8Array(1048577), sha256 }),
|
|
22279
22283
|
]) {
|
|
22280
22284
|
const job = new VerifyReviewAttachment({
|
|
22285
|
+
async readResult() { return null; },
|
|
22281
22286
|
readAttachment, async recordAttachment(_input, result) { records++; return result; },
|
|
22282
22287
|
});
|
|
22283
22288
|
await expect(job.run(input)).rejects.toThrow();
|
|
22284
22289
|
}
|
|
22285
22290
|
expect(records).toBe(0);
|
|
22286
22291
|
await expect(new VerifyReviewAttachment({
|
|
22292
|
+
async readResult() { return null; },
|
|
22287
22293
|
async readAttachment() { return { body, sha256 }; },
|
|
22288
22294
|
async recordAttachment() { throw new Error("Commit failed"); },
|
|
22289
22295
|
}).run(input)).rejects.toThrow("Commit failed");
|
|
22290
22296
|
});
|
|
22297
|
+
|
|
22298
|
+
test("a reauthorized committed result avoids another download or write", async () => {
|
|
22299
|
+
const result = { ...input, sha256, bytes: body.byteLength };
|
|
22300
|
+
const job = new VerifyReviewAttachment({
|
|
22301
|
+
async readResult() { return result; },
|
|
22302
|
+
async readAttachment() { throw new Error("Must not download again"); },
|
|
22303
|
+
async recordAttachment() { throw new Error("Must not write again"); },
|
|
22304
|
+
});
|
|
22305
|
+
expect(await job.run(input)).toEqual(result);
|
|
22306
|
+
await expect(new VerifyReviewAttachment({
|
|
22307
|
+
async readResult() { throw new Error("Access revoked"); },
|
|
22308
|
+
async readAttachment() { throw new Error("Must not download"); },
|
|
22309
|
+
async recordAttachment() { throw new Error("Must not write"); },
|
|
22310
|
+
}).run(input)).rejects.toThrow("Access revoked");
|
|
22311
|
+
});
|
|
22291
22312
|
`;
|
|
22292
22313
|
|
|
22293
22314
|
// src/shared/tools/app-starter-attachments.ts
|
|
@@ -22308,7 +22329,8 @@ ALTER TABLE public.starter_attachment_results ENABLE ROW LEVEL SECURITY;
|
|
|
22308
22329
|
var STARTER_ATTACHMENT_POSTGRES = `import { SupaCloudArtifactsClient } from "@supacloud/js";
|
|
22309
22330
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
22310
22331
|
import type { CommandDatabase, CommandTransaction } from "@supacloud/db";
|
|
22311
|
-
import
|
|
22332
|
+
import { createSchemaDecoder } from "@supacloud/elysia";
|
|
22333
|
+
import { AttachmentResult, type ReviewAttachmentInput, type ReviewAttachmentResult, type ReviewAttachmentStore } from "../review/attachment";
|
|
22312
22334
|
|
|
22313
22335
|
export interface ReviewAttachmentOptions {
|
|
22314
22336
|
database: CommandDatabase;
|
|
@@ -22329,14 +22351,13 @@ function record(value: unknown): Record<string, unknown> {
|
|
|
22329
22351
|
if (!isRecord(value)) throw new TypeError("Invalid attachment record");
|
|
22330
22352
|
return value;
|
|
22331
22353
|
}
|
|
22332
|
-
|
|
22333
|
-
|
|
22334
|
-
|
|
22335
|
-
|
|
22336
|
-
|
|
22337
|
-
throw new Error("
|
|
22338
|
-
|
|
22339
|
-
return { reviewId: item["reviewId"], artifactId: item["artifactId"], version: item["version"], sha256: item["sha256"], bytes: item["bytes"] };
|
|
22354
|
+
const durableResult = createSchemaDecoder(AttachmentResult, { normalize: false });
|
|
22355
|
+
|
|
22356
|
+
function resultFor(input: ReviewAttachmentInput, value: unknown): ReviewAttachmentResult {
|
|
22357
|
+
const result = durableResult(value);
|
|
22358
|
+
if (result.reviewId !== input.reviewId || result.version !== input.version
|
|
22359
|
+
|| result.artifactId !== input.artifactId) throw new Error("Conflicting durable attachment result");
|
|
22360
|
+
return result;
|
|
22340
22361
|
}
|
|
22341
22362
|
|
|
22342
22363
|
export async function createReviewAttachmentAdapters(options: ReviewAttachmentOptions): Promise<{
|
|
@@ -22382,6 +22403,16 @@ export async function createReviewAttachmentAdapters(options: ReviewAttachmentOp
|
|
|
22382
22403
|
]);
|
|
22383
22404
|
},
|
|
22384
22405
|
store: {
|
|
22406
|
+
async readResult(input) {
|
|
22407
|
+
return database.transaction(async tx => {
|
|
22408
|
+
await authorize(tx, input);
|
|
22409
|
+
const saved = rows(await tx.query(
|
|
22410
|
+
"SELECT result FROM public.starter_attachment_results WHERE review_id=$1 AND version=$2 AND artifact_id=$3",
|
|
22411
|
+
[input.reviewId, input.version, input.artifactId],
|
|
22412
|
+
));
|
|
22413
|
+
return saved.length === 0 ? null : resultFor(input, saved[0]?.["result"]);
|
|
22414
|
+
});
|
|
22415
|
+
},
|
|
22385
22416
|
async readAttachment(input) {
|
|
22386
22417
|
const allowed = await database.transaction(tx => authorize(tx, input));
|
|
22387
22418
|
const artifact = await artifacts.get(input.artifactId);
|
|
@@ -22395,24 +22426,19 @@ export async function createReviewAttachmentAdapters(options: ReviewAttachmentOp
|
|
|
22395
22426
|
return { body: new Uint8Array(await downloaded.data.arrayBuffer()), sha256: artifact.sha256 };
|
|
22396
22427
|
},
|
|
22397
22428
|
async recordAttachment(input, result) {
|
|
22398
|
-
|
|
22399
|
-
|| result.artifactId !== input.artifactId || !/^[a-f0-9]{64}$/.test(result.sha256)
|
|
22400
|
-
|| !Number.isSafeInteger(result.bytes) || result.bytes < 1 || result.bytes > 1048576) {
|
|
22401
|
-
throw new Error("Invalid attachment result");
|
|
22402
|
-
}
|
|
22429
|
+
const decoded = resultFor(input, result);
|
|
22403
22430
|
return database.transaction(async tx => {
|
|
22404
22431
|
await authorize(tx, input);
|
|
22405
22432
|
await tx.query(
|
|
22406
22433
|
"INSERT INTO public.starter_attachment_results VALUES($1,$2,$3,$4::text::jsonb) ON CONFLICT DO NOTHING",
|
|
22407
|
-
[input.reviewId, input.version, input.artifactId, JSON.stringify(
|
|
22434
|
+
[input.reviewId, input.version, input.artifactId, JSON.stringify(decoded)],
|
|
22408
22435
|
);
|
|
22409
22436
|
const saved = rows(await tx.query(
|
|
22410
22437
|
"SELECT result FROM public.starter_attachment_results WHERE review_id=$1 AND version=$2 AND artifact_id=$3",
|
|
22411
22438
|
[input.reviewId, input.version, input.artifactId],
|
|
22412
22439
|
))[0]?.["result"];
|
|
22413
|
-
const value =
|
|
22414
|
-
if (value.
|
|
22415
|
-
|| value.sha256 !== result.sha256 || value.bytes !== result.bytes) {
|
|
22440
|
+
const value = resultFor(input, saved);
|
|
22441
|
+
if (value.sha256 !== decoded.sha256 || value.bytes !== decoded.bytes) {
|
|
22416
22442
|
throw new Error("Conflicting durable attachment result");
|
|
22417
22443
|
}
|
|
22418
22444
|
return value;
|
|
@@ -22958,20 +22984,21 @@ ALTER TABLE public.starter_application ENABLE ROW LEVEL SECURITY;
|
|
|
22958
22984
|
ALTER TABLE public.starter_reviews ENABLE ROW LEVEL SECURITY;
|
|
22959
22985
|
ALTER TABLE public.starter_members ENABLE ROW LEVEL SECURITY;
|
|
22960
22986
|
`;
|
|
22961
|
-
var STARTER_REVIEW_POSTGRES = `import {
|
|
22962
|
-
import { createTransactionalCommand, plaintextCommandInput } from "@supacloud/commands";
|
|
22987
|
+
var STARTER_REVIEW_POSTGRES = `import { plaintextCommandInput } from "@supacloud/commands";
|
|
22963
22988
|
import { CommandError } from "@supacloud/contracts";
|
|
22964
22989
|
import { createPostgresCommandStore, type CommandDatabase, type CommandTransaction } from "@supacloud/db";
|
|
22965
22990
|
import {
|
|
22966
|
-
ApplicationError, createSupAuthRequestContext, requireTrustedIdentity,
|
|
22991
|
+
ApplicationError, createSchemaDecoder, createSupAuthRequestContext, requireTrustedIdentity,
|
|
22992
|
+
createTransactionalCommandGovernance,
|
|
22967
22993
|
type CommandInvocation, type SupAuthContextOptions,
|
|
22968
22994
|
} from "@supacloud/elysia";
|
|
22969
22995
|
import type { AppAdapters } from "../application";
|
|
22970
22996
|
import type { ReviewStore } from "../review/review";
|
|
22971
22997
|
import type { ReviewUploadPort } from "../review/uploads";
|
|
22998
|
+
import { ApprovalInput, ReviewResult } from "../review/contracts";
|
|
22972
22999
|
|
|
22973
|
-
|
|
22974
|
-
|
|
23000
|
+
const input = createSchemaDecoder(ApprovalInput, { normalize: false });
|
|
23001
|
+
const result = createSchemaDecoder(ReviewResult, { normalize: false });
|
|
22975
23002
|
|
|
22976
23003
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
22977
23004
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
@@ -22988,19 +23015,6 @@ function isPublicConflict(value: unknown): boolean {
|
|
|
22988
23015
|
return value instanceof ApplicationError && value.status === 409
|
|
22989
23016
|
|| isRecord(value) && value["expose"] === true && value["status"] === 409 && typeof value["code"] === "string";
|
|
22990
23017
|
}
|
|
22991
|
-
function input(value: unknown): Approval {
|
|
22992
|
-
const item = record(value);
|
|
22993
|
-
if (typeof item["id"] !== "string" || !item["id"].length || typeof item["expectedVersion"] !== "number"
|
|
22994
|
-
|| !Number.isSafeInteger(item["expectedVersion"]) || item["expectedVersion"] < 1) throw new TypeError("Invalid approval input");
|
|
22995
|
-
return { id: item["id"], expectedVersion: item["expectedVersion"] };
|
|
22996
|
-
}
|
|
22997
|
-
function result(value: unknown): Review {
|
|
22998
|
-
const item = record(value);
|
|
22999
|
-
if ((item["state"] !== "draft" && item["state"] !== "approved") || typeof item["version"] !== "number"
|
|
23000
|
-
|| !Number.isSafeInteger(item["version"]) || item["version"] < 1) throw new TypeError("Invalid review result");
|
|
23001
|
-
return { state: item["state"], version: item["version"] };
|
|
23002
|
-
}
|
|
23003
|
-
|
|
23004
23018
|
export interface ReviewPostgresOptions {
|
|
23005
23019
|
database: CommandDatabase;
|
|
23006
23020
|
tenantId: string;
|
|
@@ -23038,12 +23052,7 @@ export async function createReviewPostgresAdapters(options: ReviewPostgresOption
|
|
|
23038
23052
|
} : null;
|
|
23039
23053
|
},
|
|
23040
23054
|
});
|
|
23041
|
-
const
|
|
23042
|
-
const transaction = () => {
|
|
23043
|
-
const tx = scope.getStore();
|
|
23044
|
-
if (!tx) throw new Error("Review storage requires its durable command transaction");
|
|
23045
|
-
return tx;
|
|
23046
|
-
};
|
|
23055
|
+
const transaction = () => commandBinding.transaction();
|
|
23047
23056
|
const store: ReviewStore = {
|
|
23048
23057
|
...options.uploads,
|
|
23049
23058
|
async get(table, id) {
|
|
@@ -23072,64 +23081,30 @@ export async function createReviewPostgresAdapters(options: ReviewPostgresOption
|
|
|
23072
23081
|
}
|
|
23073
23082
|
return who.subject;
|
|
23074
23083
|
}
|
|
23075
|
-
const
|
|
23076
|
-
|
|
23077
|
-
|
|
23078
|
-
|
|
23079
|
-
|
|
23080
|
-
|
|
23081
|
-
|
|
23082
|
-
|
|
23083
|
-
|
|
23084
|
-
|
|
23085
|
-
|
|
23086
|
-
|
|
23087
|
-
|
|
23088
|
-
|
|
23089
|
-
|
|
23090
|
-
|
|
23091
|
-
|
|
23092
|
-
|
|
23093
|
-
|
|
23094
|
-
"SELECT r.id FROM public.starter_reviews r JOIN public.starter_members m ON m.subject=r.owner_id " +
|
|
23095
|
-
"CROSS JOIN public.starter_application a " +
|
|
23096
|
-
"WHERE r.id=$1 AND r.owner_id=$2 AND m.enabled AND m.can_approve " +
|
|
23097
|
-
"AND a.singleton AND a.project_id=$3 AND a.tenant_id=$4 FOR UPDATE OF r,m FOR SHARE OF a",
|
|
23098
|
-
[request.id, who.actorId, projectId, who.tenantId],
|
|
23099
|
-
));
|
|
23100
|
-
return found.length === 1 ? "allow" : "deny";
|
|
23101
|
-
},
|
|
23102
|
-
execute: tx => scope.run(tx, async () => {
|
|
23103
|
-
try { return await next(); }
|
|
23104
|
-
catch (error) {
|
|
23105
|
-
if (isPublicConflict(error)) {
|
|
23106
|
-
throw new CommandError("COMMAND_IDEMPOTENCY_CONFLICT");
|
|
23107
|
-
}
|
|
23108
|
-
throw error;
|
|
23109
|
-
}
|
|
23110
|
-
}),
|
|
23111
|
-
audit: { event: "review.approved", details: request => ({ reviewId: request.id }) },
|
|
23112
|
-
});
|
|
23113
|
-
try {
|
|
23114
|
-
const receipt = await command.execute(
|
|
23115
|
-
{ actorId: actor(invocation), tenantId }, requireIdempotencyKey(invocation),
|
|
23116
|
-
{ id: invocation.input.params["id"], expectedVersion: record(invocation.input.body)["expectedVersion"] },
|
|
23117
|
-
invocation.request.signal,
|
|
23118
|
-
);
|
|
23119
|
-
if (receipt.status !== "confirmed") throw new CommandError("COMMAND_OUTCOME_UNKNOWN");
|
|
23120
|
-
return receipt.result;
|
|
23121
|
-
} catch (error) {
|
|
23122
|
-
if (!(error instanceof CommandError)) throw error;
|
|
23123
|
-
const status = error.code === "COMMAND_REJECTED" ? 403
|
|
23124
|
-
: error.code === "COMMAND_IDEMPOTENCY_CONFLICT" ? 409 : 503;
|
|
23125
|
-
throw new ApplicationError(error.code, { code: error.code, status });
|
|
23126
|
-
}
|
|
23084
|
+
const commandBinding = createTransactionalCommandGovernance({
|
|
23085
|
+
permission: "review.approve",
|
|
23086
|
+
identity: invocation => ({ actorId: actor(invocation), tenantId }),
|
|
23087
|
+
input: invocation => ({
|
|
23088
|
+
...record(invocation.input.body), id: invocation.input.params["id"],
|
|
23089
|
+
}),
|
|
23090
|
+
rejection: error => isPublicConflict(error) ? new CommandError("COMMAND_IDEMPOTENCY_CONFLICT") : undefined,
|
|
23091
|
+
definition: {
|
|
23092
|
+
name: "review.approve", store: createPostgresCommandStore(database),
|
|
23093
|
+
inputCodec: plaintextCommandInput, input, result,
|
|
23094
|
+
async authorize(who, request, tx) {
|
|
23095
|
+
const found = rows(await tx.query(
|
|
23096
|
+
"SELECT r.id FROM public.starter_reviews r JOIN public.starter_members m ON m.subject=r.owner_id " +
|
|
23097
|
+
"CROSS JOIN public.starter_application a " +
|
|
23098
|
+
"WHERE r.id=$1 AND r.owner_id=$2 AND m.enabled AND m.can_approve " +
|
|
23099
|
+
"AND a.singleton AND a.project_id=$3 AND a.tenant_id=$4 FOR UPDATE OF r,m FOR SHARE OF a",
|
|
23100
|
+
[request.id, who.actorId, projectId, who.tenantId],
|
|
23101
|
+
));
|
|
23102
|
+
return found.length === 1 ? "allow" : "deny";
|
|
23127
23103
|
},
|
|
23128
|
-
|
|
23129
|
-
// The durable command owns the authoritative audit and receipt in the same transaction.
|
|
23130
|
-
audit: { succeeded() { transaction(); }, failed() {} },
|
|
23104
|
+
audit: { event: "review.approved", details: request => ({ reviewId: request.id }) },
|
|
23131
23105
|
},
|
|
23132
|
-
};
|
|
23106
|
+
});
|
|
23107
|
+
return { deps: { dbClient: store }, requestContext, commandGovernance: commandBinding.governance };
|
|
23133
23108
|
}
|
|
23134
23109
|
`;
|
|
23135
23110
|
var STARTER_REVIEW_POSTGRES_TEST = `import { expect, test } from "bun:test";
|
|
@@ -23166,9 +23141,9 @@ test("PostgreSQL host never falls back to demo identity or unbound business stor
|
|
|
23166
23141
|
await expect(adapters.requestContext(new Request("http://localhost/reviews/health")))
|
|
23167
23142
|
.rejects.toMatchObject({ code: "AUTHENTICATION_REQUIRED" });
|
|
23168
23143
|
const store = adapters.deps["dbClient"] as ReviewStore;
|
|
23169
|
-
await expect(store.get("reviews", "example")).rejects.toThrow("
|
|
23144
|
+
await expect(store.get("reviews", "example")).rejects.toThrow("not active");
|
|
23170
23145
|
await expect(store.set("reviews", "example", { state: "approved", version: 2 }))
|
|
23171
|
-
.rejects.toThrow("
|
|
23146
|
+
.rejects.toThrow("not active");
|
|
23172
23147
|
expect(db.queries).toHaveLength(1);
|
|
23173
23148
|
});
|
|
23174
23149
|
`;
|
|
@@ -23454,6 +23429,58 @@ export default defineConfig({
|
|
|
23454
23429
|
};
|
|
23455
23430
|
}
|
|
23456
23431
|
|
|
23432
|
+
// src/shared/tools/app-starter-query.ts
|
|
23433
|
+
var STARTER_REVIEW_QUERY = `import type { SupabaseClient } from "@supabase/supabase-js";
|
|
23434
|
+
import type { ReviewRow } from "../../db/schema";
|
|
23435
|
+
|
|
23436
|
+
// This read projection uses SQL column names shared with the Drizzle source.
|
|
23437
|
+
// Do not expose camelCase Drizzle property names as PostgREST column names.
|
|
23438
|
+
export type ReviewSummary = Pick<ReviewRow, "id" | "state" | "version">;
|
|
23439
|
+
export type ReviewReadDatabase = {
|
|
23440
|
+
public: {
|
|
23441
|
+
Tables: {
|
|
23442
|
+
starter_reviews: {
|
|
23443
|
+
Row: ReviewSummary;
|
|
23444
|
+
Insert: never;
|
|
23445
|
+
Update: never;
|
|
23446
|
+
Relationships: [];
|
|
23447
|
+
};
|
|
23448
|
+
};
|
|
23449
|
+
Views: Record<never, never>;
|
|
23450
|
+
Functions: Record<never, never>;
|
|
23451
|
+
};
|
|
23452
|
+
};
|
|
23453
|
+
|
|
23454
|
+
export interface ReviewPageOptions {
|
|
23455
|
+
page?: number;
|
|
23456
|
+
pageSize?: number;
|
|
23457
|
+
signal?: AbortSignal;
|
|
23458
|
+
}
|
|
23459
|
+
|
|
23460
|
+
// Reuse the application's user-scoped client. Identity verification and row
|
|
23461
|
+
// authorization belong to the gateway and database, never a caller-supplied owner.
|
|
23462
|
+
export async function listReviews(
|
|
23463
|
+
client: SupabaseClient<ReviewReadDatabase>,
|
|
23464
|
+
{ page = 1, pageSize = 25, signal }: ReviewPageOptions = {},
|
|
23465
|
+
) {
|
|
23466
|
+
if (!Number.isSafeInteger(page) || page < 1
|
|
23467
|
+
|| !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100
|
|
23468
|
+
|| !Number.isSafeInteger(page * pageSize)) {
|
|
23469
|
+
throw new RangeError("Invalid review page: page >= 1 and pageSize between 1 and 100 are required");
|
|
23470
|
+
}
|
|
23471
|
+
signal?.throwIfAborted();
|
|
23472
|
+
const start = (page - 1) * pageSize;
|
|
23473
|
+
const query = client.from("starter_reviews")
|
|
23474
|
+
.select("id,state,version", { count: "exact" })
|
|
23475
|
+
.order("id", { ascending: true })
|
|
23476
|
+
.range(start, start + pageSize - 1);
|
|
23477
|
+
const { data, error, count } = await (signal ? query.abortSignal(signal) : query);
|
|
23478
|
+
if (error) throw error;
|
|
23479
|
+
if (data === null || count === null) throw new Error("Incomplete review page response");
|
|
23480
|
+
return { data, total: count, page, pageSize, hasNextPage: start + data.length < count };
|
|
23481
|
+
}
|
|
23482
|
+
`;
|
|
23483
|
+
|
|
23457
23484
|
// src/shared/tools/app-starter.ts
|
|
23458
23485
|
var { compiler: compilerMetadata2, app: appMetadata2, elysia: elysiaMetadata2, commands: commandsMetadata, contracts: contractsMetadata, db: dbMetadata, sdk: sdkMetadata } = starter_metadata_default.packages;
|
|
23459
23486
|
var json2 = (value) => `${JSON.stringify(value, null, 2)}
|
|
@@ -23461,6 +23488,7 @@ var json2 = (value) => `${JSON.stringify(value, null, 2)}
|
|
|
23461
23488
|
function appStarterFiles(name) {
|
|
23462
23489
|
return {
|
|
23463
23490
|
...starterDatabaseFiles(),
|
|
23491
|
+
"src/review/list-reviews.ts": STARTER_REVIEW_QUERY,
|
|
23464
23492
|
"package.json": json2({
|
|
23465
23493
|
name,
|
|
23466
23494
|
version: "0.0.0",
|
|
@@ -23495,8 +23523,8 @@ function appStarterFiles(name) {
|
|
|
23495
23523
|
"drizzle-orm": dbMetadata.peerDependencies["drizzle-orm"],
|
|
23496
23524
|
"@supacloud/js": `^${sdkMetadata.version}`,
|
|
23497
23525
|
"@supabase/supabase-js": sdkMetadata.peerDependencies["@supabase/supabase-js"],
|
|
23498
|
-
effect: "4.0.
|
|
23499
|
-
elysia: "2.0.0-beta.
|
|
23526
|
+
effect: "4.0.5",
|
|
23527
|
+
elysia: "2.0.0-beta.32",
|
|
23500
23528
|
rxjs: appMetadata2.dependencies.rxjs
|
|
23501
23529
|
},
|
|
23502
23530
|
devDependencies: {
|
|
@@ -23750,11 +23778,10 @@ import {
|
|
|
23750
23778
|
Body, Command, Controller, DB_CLIENT, Get, Inject, Param, Post,
|
|
23751
23779
|
defineFeatureSlice, defineFeatureSpec, type Aspect,
|
|
23752
23780
|
} from "@supacloud/app";
|
|
23753
|
-
import { ApplicationError, assertFeatureTransition } from "@supacloud/elysia";
|
|
23754
|
-
import { ApproveBody, HealthResult, Params, ReviewResult } from "./contracts";
|
|
23781
|
+
import { ApplicationError, assertFeatureTransition, createSchemaDecoder } from "@supacloud/elysia";
|
|
23782
|
+
import { ApproveBody, HealthResult, Params, ReviewResult, type ApprovalBody, type Review } from "./contracts";
|
|
23755
23783
|
import { VerifyReviewAttachment } from "./attachment";
|
|
23756
23784
|
import { ReviewUploads, ReviewUploadsController, type ReviewUploadPort } from "./uploads";
|
|
23757
|
-
import type { ReviewRow } from "../../db/schema";
|
|
23758
23785
|
|
|
23759
23786
|
export const reviewSpec = defineFeatureSpec({
|
|
23760
23787
|
name: "review",
|
|
@@ -23769,7 +23796,6 @@ export const reviewSpec = defineFeatureSpec({
|
|
|
23769
23796
|
},
|
|
23770
23797
|
});
|
|
23771
23798
|
|
|
23772
|
-
type Review = Pick<ReviewRow, "state" | "version">;
|
|
23773
23799
|
// Production implementations must bind both operations to the current transaction.
|
|
23774
23800
|
export interface ReviewStore extends Partial<ReviewUploadPort> {
|
|
23775
23801
|
get(table: string, key: string): unknown;
|
|
@@ -23782,13 +23808,12 @@ export const requireRequest: Aspect = (context, next) => {
|
|
|
23782
23808
|
return next();
|
|
23783
23809
|
};
|
|
23784
23810
|
|
|
23811
|
+
const decodeReview = createSchemaDecoder(ReviewResult, { normalize: false });
|
|
23785
23812
|
function readReview(value: unknown): Review {
|
|
23786
|
-
|
|
23787
|
-
|
|
23788
|
-
!("version" in value) || typeof value.version !== "number") {
|
|
23813
|
+
try { return decodeReview(value); }
|
|
23814
|
+
catch {
|
|
23789
23815
|
throw new ApplicationError("Review not found", { status: 404, code: "REVIEW_NOT_FOUND" });
|
|
23790
23816
|
}
|
|
23791
|
-
return { state: value.state, version: value.version };
|
|
23792
23817
|
}
|
|
23793
23818
|
|
|
23794
23819
|
@Command({
|
|
@@ -23820,7 +23845,7 @@ type ReviewError =
|
|
|
23820
23845
|
| { readonly _tag: "FEATURE_TRANSITION_CONFLICT" }
|
|
23821
23846
|
| { readonly _tag: "COMMAND_IDEMPOTENCY_CONFLICT" }
|
|
23822
23847
|
| { readonly _tag: "COMMAND_REJECTED" }
|
|
23823
|
-
| { readonly _tag: "
|
|
23848
|
+
| { readonly _tag: "COMMAND_BINDING_MISMATCH" }
|
|
23824
23849
|
| { readonly _tag: "COMMAND_OUTCOME_UNKNOWN" };
|
|
23825
23850
|
|
|
23826
23851
|
@Controller("/reviews")
|
|
@@ -23849,13 +23874,13 @@ export class ReviewController {
|
|
|
23849
23874
|
{ tag: "FEATURE_TRANSITION_CONFLICT", status: 409, code: "FEATURE_TRANSITION_CONFLICT" },
|
|
23850
23875
|
{ tag: "COMMAND_IDEMPOTENCY_CONFLICT", status: 409, code: "COMMAND_IDEMPOTENCY_CONFLICT" },
|
|
23851
23876
|
{ tag: "COMMAND_REJECTED", status: 403, code: "COMMAND_REJECTED" },
|
|
23852
|
-
{ tag: "
|
|
23877
|
+
{ tag: "COMMAND_BINDING_MISMATCH", status: 501, code: "COMMAND_BINDING_MISMATCH" },
|
|
23853
23878
|
{ tag: "COMMAND_OUTCOME_UNKNOWN", status: 503, code: "COMMAND_OUTCOME_UNKNOWN" },
|
|
23854
23879
|
],
|
|
23855
23880
|
retry: "none",
|
|
23856
23881
|
},
|
|
23857
23882
|
})
|
|
23858
|
-
approve(@Param("id") id: string, @Body() body:
|
|
23883
|
+
approve(@Param("id") id: string, @Body() body: ApprovalBody): Effect.Effect<Review, ReviewError, never> {
|
|
23859
23884
|
return Effect.promise(() => this.approveReview.execute(id, body.expectedVersion));
|
|
23860
23885
|
}
|
|
23861
23886
|
}
|
|
@@ -23867,13 +23892,16 @@ export const ReviewFeature = defineFeatureSlice({
|
|
|
23867
23892
|
});
|
|
23868
23893
|
`,
|
|
23869
23894
|
"src/review/attachment.ts": STARTER_REVIEW_JOB,
|
|
23870
|
-
"src/review/contracts.ts": `import { t } from "elysia";
|
|
23895
|
+
"src/review/contracts.ts": `import { t, type UnwrapSchema } from "elysia";
|
|
23871
23896
|
export const Params = t.Object({ id: t.String({ minLength: 1 }) });
|
|
23872
|
-
export const ApproveBody = t.Object({ expectedVersion: t.Integer({ minimum: 1 }) });
|
|
23897
|
+
export const ApproveBody = t.Object({ expectedVersion: t.Integer({ minimum: 1, maximum: 9007199254740991 }) });
|
|
23898
|
+
export const ApprovalInput = t.Object({ ...Params.properties, ...ApproveBody.properties }, { additionalProperties: false });
|
|
23899
|
+
export type ApprovalBody = UnwrapSchema<typeof ApproveBody>;
|
|
23873
23900
|
export const ReviewResult = t.Object({
|
|
23874
23901
|
state: t.Union([t.Literal("draft"), t.Literal("approved")]),
|
|
23875
|
-
version: t.Integer({ minimum: 1 }),
|
|
23902
|
+
version: t.Integer({ minimum: 1, maximum: 9007199254740991 }),
|
|
23876
23903
|
});
|
|
23904
|
+
export type Review = UnwrapSchema<typeof ReviewResult>;
|
|
23877
23905
|
export const HealthResult = t.Object({ ok: t.Boolean() });
|
|
23878
23906
|
export const UploadParams = t.Object({ id: t.String({ format: "uuid" }) });
|
|
23879
23907
|
export const UploadBody = t.Object({
|
|
@@ -24015,6 +24043,18 @@ Ordinary reads use PostgREST through the existing user-scoped
|
|
|
24015
24043
|
Use the caller's session, RLS and grants; never introduce a second session store.
|
|
24016
24044
|
Business writes continue through the governed Command/RPC boundary.
|
|
24017
24045
|
|
|
24046
|
+
Use listReviews from src/review/list-reviews.ts for the paginated review list.
|
|
24047
|
+
Type the existing client with ReviewReadDatabase (a read-only projection derived
|
|
24048
|
+
from db/schema.ts), then call listReviews(client, { page: 1, pageSize: 25, signal }).
|
|
24049
|
+
The helper returns data, total, page, pageSize and hasNextPage, orders by the unique
|
|
24050
|
+
id and preserves PostgREST errors. Pages are one-based and limited to 100 rows.
|
|
24051
|
+
Exact counts obey RLS but may be expensive on large tables; offset pages are not
|
|
24052
|
+
a cross-request snapshot when records change. No owner filter replaces RLS.
|
|
24053
|
+
Do not create a service-role client or a second auth session for this helper.
|
|
24054
|
+
Existing starters can add this file without a new migration only if their reviewed
|
|
24055
|
+
review-table grants and identity-mapping RLS from migration 003 are already applied.
|
|
24056
|
+
The projection is not a full database export and grants no write access.
|
|
24057
|
+
|
|
24018
24058
|
GraphQL is not generated or enabled by this starter. For a specific Query Contract,
|
|
24019
24059
|
explicitly configure graphql.schema with a role-scoped database export and add
|
|
24020
24060
|
checked query documents. Drizzle and append-only migrations remain the database
|
|
@@ -24169,6 +24209,15 @@ the existing Workflow and invokes the compiled Job with a ReviewAttachmentStore.
|
|
|
24169
24209
|
Its reader authorizes the worker independently; its writer rechecks the review
|
|
24170
24210
|
revision/artifact binding and persists the result idempotently before acknowledgement.
|
|
24171
24211
|
After a lost acknowledgement, reconcile that result instead of repeating effects.
|
|
24212
|
+
The generated Job first calls store.readResult. The PostgreSQL adapter rechecks
|
|
24213
|
+
current permission, ownership, revision and project/tenant binding inside the
|
|
24214
|
+
read transaction, then validates a stored result with AttachmentResult. A valid
|
|
24215
|
+
committed result skips Storage download and result insertion on redelivery.
|
|
24216
|
+
Revoked access or a malformed/conflicting result fails closed, not as a cache miss.
|
|
24217
|
+
Existing custom ReviewAttachmentStore implementations must add readResult with
|
|
24218
|
+
these same authorization and validation guarantees when adopting this Job.
|
|
24219
|
+
This does not automatically restart a halted worker, reset the attempt budget,
|
|
24220
|
+
or make external side effects atomic with the database.
|
|
24172
24221
|
|
|
24173
24222
|
src/host/review-attachments.ts supplies createReviewAttachmentAdapters with a
|
|
24174
24223
|
durable store and enqueue callback for the approval adapter's afterApproved hook.
|
|
@@ -30513,7 +30562,7 @@ async function runProjectDeployment(http, args) {
|
|
|
30513
30562
|
// package.json
|
|
30514
30563
|
var package_default = {
|
|
30515
30564
|
name: "@supacloud/cli",
|
|
30516
|
-
version: "0.
|
|
30565
|
+
version: "0.101.2",
|
|
30517
30566
|
description: "Project-scoped CLI for SupaCloud users",
|
|
30518
30567
|
type: "module",
|
|
30519
30568
|
main: "./dist/index.js",
|
|
@@ -30565,21 +30614,21 @@ var package_default = {
|
|
|
30565
30614
|
directory: "packages/cli"
|
|
30566
30615
|
},
|
|
30567
30616
|
dependencies: {
|
|
30568
|
-
"@supacloud/compiler": "^0.46.
|
|
30569
|
-
"@supacloud/delivery": "0.18.
|
|
30570
|
-
"@supacloud/db": "0.17.
|
|
30571
|
-
typebox: "1.3.
|
|
30617
|
+
"@supacloud/compiler": "^0.46.4",
|
|
30618
|
+
"@supacloud/delivery": "0.18.1",
|
|
30619
|
+
"@supacloud/db": "0.17.1",
|
|
30620
|
+
typebox: "1.3.37"
|
|
30572
30621
|
},
|
|
30573
30622
|
overrides: {
|
|
30574
|
-
"@supacloud/contracts": "0.
|
|
30575
|
-
"@supacloud/delivery": "0.18.
|
|
30576
|
-
"@graphql-tools/utils": "^12.0.
|
|
30623
|
+
"@supacloud/contracts": "0.10.0",
|
|
30624
|
+
"@supacloud/delivery": "0.18.1",
|
|
30625
|
+
"@graphql-tools/utils": "^12.0.4"
|
|
30577
30626
|
},
|
|
30578
30627
|
devDependencies: {
|
|
30579
|
-
"@supacloud/elysia": "
|
|
30628
|
+
"@supacloud/elysia": "0.31.1",
|
|
30580
30629
|
"@types/bun": "^1.4.3",
|
|
30581
|
-
effect: "4.0.
|
|
30582
|
-
elysia: "2.0.0-beta.
|
|
30630
|
+
effect: "4.0.5",
|
|
30631
|
+
elysia: "2.0.0-beta.32",
|
|
30583
30632
|
typescript: "^7.0.2"
|
|
30584
30633
|
}
|
|
30585
30634
|
};
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export declare const STARTER_ATTACHMENT_SCHEMA = "-- Apply with the migration owner, never from an application process.\nCREATE TABLE public.starter_attachments (\n review_id text PRIMARY KEY REFERENCES public.starter_reviews(id),\n artifact_id uuid NOT NULL UNIQUE, owner_id text NOT NULL, run_id uuid NOT NULL UNIQUE,\n object_path text NOT NULL UNIQUE\n);\nCREATE TABLE public.starter_attachment_results (\n review_id text NOT NULL REFERENCES public.starter_reviews(id),\n version integer NOT NULL CHECK (version >= 2), artifact_id uuid NOT NULL,\n result jsonb NOT NULL, PRIMARY KEY(review_id,version,artifact_id)\n);\nALTER TABLE public.starter_attachments ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.starter_attachment_results ENABLE ROW LEVEL SECURITY;\n";
|
|
2
|
-
export declare const STARTER_ATTACHMENT_POSTGRES = "import { SupaCloudArtifactsClient } from \"@supacloud/js\";\nimport type { SupabaseClient } from \"@supabase/supabase-js\";\nimport type { CommandDatabase, CommandTransaction } from \"@supacloud/db\";\nimport
|
|
2
|
+
export declare const STARTER_ATTACHMENT_POSTGRES = "import { SupaCloudArtifactsClient } from \"@supacloud/js\";\nimport type { SupabaseClient } from \"@supabase/supabase-js\";\nimport type { CommandDatabase, CommandTransaction } from \"@supacloud/db\";\nimport { createSchemaDecoder } from \"@supacloud/elysia\";\nimport { AttachmentResult, type ReviewAttachmentInput, type ReviewAttachmentResult, type ReviewAttachmentStore } from \"../review/attachment\";\n\nexport interface ReviewAttachmentOptions {\n database: CommandDatabase;\n // Provision this client for the same project as the database; never use a caller's bearer token.\n service: SupabaseClient;\n projectId: string;\n tenantId: string;\n}\n\nfunction rows(value: unknown): Record<string, unknown>[] {\n if (!Array.isArray(value)) throw new TypeError(\"Invalid attachment database result\");\n return value.map(record);\n}\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\nfunction record(value: unknown): Record<string, unknown> {\n if (!isRecord(value)) throw new TypeError(\"Invalid attachment record\");\n return value;\n}\nconst durableResult = createSchemaDecoder(AttachmentResult, { normalize: false });\n\nfunction resultFor(input: ReviewAttachmentInput, value: unknown): ReviewAttachmentResult {\n const result = durableResult(value);\n if (result.reviewId !== input.reviewId || result.version !== input.version\n || result.artifactId !== input.artifactId) throw new Error(\"Conflicting durable attachment result\");\n return result;\n}\n\nexport async function createReviewAttachmentAdapters(options: ReviewAttachmentOptions): Promise<{\n store: ReviewAttachmentStore;\n enqueue(tx: CommandTransaction, reviewId: string, version: number): Promise<void>;\n}> {\n const { database, service, projectId, tenantId } = options;\n if (!projectId.trim() || !tenantId.trim()) throw new TypeError(\"Attachment project/tenant is required\");\n const artifacts = new SupaCloudArtifactsClient(service);\n const binding = rows(await database.transaction(tx => tx.query(\n \"SELECT project_id,tenant_id FROM public.starter_application WHERE singleton\",\n )));\n if (binding.length !== 1 || binding[0]?.[\"project_id\"] !== projectId || binding[0]?.[\"tenant_id\"] !== tenantId) {\n throw new Error(\"Attachment database project/tenant binding mismatch\");\n }\n async function authorize(tx: CommandTransaction, input: ReviewAttachmentInput) {\n const allowed = rows(await tx.query(\n \"SELECT a.object_path,a.run_id FROM public.starter_attachments a \" +\n \"JOIN public.starter_reviews r ON r.id=a.review_id AND r.owner_id=a.owner_id \" +\n \"JOIN public.starter_members m ON m.subject=a.owner_id AND m.enabled AND m.can_approve \" +\n \"CROSS JOIN public.starter_application p \" +\n \"WHERE a.review_id=$1 AND a.artifact_id=$2 AND r.version=$3 AND r.state='approved' \" +\n \"AND p.singleton AND p.project_id=$4 AND p.tenant_id=$5 \" +\n \"FOR UPDATE OF r,a,m FOR SHARE OF p\",\n [input.reviewId, input.artifactId, input.version, projectId, tenantId],\n ));\n if (allowed.length !== 1 || typeof allowed[0]?.[\"object_path\"] !== \"string\") {\n throw new Error(\"Attachment worker authorization or review revision is no longer valid\");\n }\n return allowed[0];\n }\n return {\n async enqueue(tx, reviewId, version) {\n const found = rows(await tx.query(\n \"SELECT artifact_id FROM public.starter_attachments WHERE review_id=$1\", [reviewId],\n ))[0];\n if (!found) return;\n const input = { reviewId, version, artifactId: String(found[\"artifact_id\"]) };\n const allowed = await authorize(tx, input);\n // Bind serialized JSON as text so drivers cannot encode the string as a JSON scalar.\n await tx.query(\"SELECT supacloud_workflows.start_run($1::uuid,$2,'1','verify',$3::text::jsonb,3)\", [\n String(allowed[\"run_id\"]), \"review.verify-attachment\", JSON.stringify(input),\n ]);\n },\n store: {\n async readResult(input) {\n return database.transaction(async tx => {\n await authorize(tx, input);\n const saved = rows(await tx.query(\n \"SELECT result FROM public.starter_attachment_results WHERE review_id=$1 AND version=$2 AND artifact_id=$3\",\n [input.reviewId, input.version, input.artifactId],\n ));\n return saved.length === 0 ? null : resultFor(input, saved[0]?.[\"result\"]);\n });\n },\n async readAttachment(input) {\n const allowed = await database.transaction(tx => authorize(tx, input));\n const artifact = await artifacts.get(input.artifactId);\n if (!artifact || artifact.bucketId !== \"review-attachments\"\n || artifact.objectPath !== allowed[\"object_path\"] || artifact.artifactType !== \"review.attachment\"\n || artifact.mimeType !== \"text/plain\" || !/^[1-9][0-9]*$/.test(artifact.sizeBytes)\n || BigInt(artifact.sizeBytes) > 1048576n) throw new Error(\"Invalid attachment artifact\");\n const downloaded = await service.storage.from(artifact.bucketId).download(artifact.objectPath);\n if (downloaded.error || !downloaded.data) throw new Error(\"Attachment download failed\");\n if (downloaded.data.size !== Number(artifact.sizeBytes)) throw new Error(\"Attachment size mismatch\");\n return { body: new Uint8Array(await downloaded.data.arrayBuffer()), sha256: artifact.sha256 };\n },\n async recordAttachment(input, result) {\n const decoded = resultFor(input, result);\n return database.transaction(async tx => {\n await authorize(tx, input);\n await tx.query(\n \"INSERT INTO public.starter_attachment_results VALUES($1,$2,$3,$4::text::jsonb) ON CONFLICT DO NOTHING\",\n [input.reviewId, input.version, input.artifactId, JSON.stringify(decoded)],\n );\n const saved = rows(await tx.query(\n \"SELECT result FROM public.starter_attachment_results WHERE review_id=$1 AND version=$2 AND artifact_id=$3\",\n [input.reviewId, input.version, input.artifactId],\n ))[0]?.[\"result\"];\n const value = resultFor(input, saved);\n if (value.sha256 !== decoded.sha256 || value.bytes !== decoded.bytes) {\n throw new Error(\"Conflicting durable attachment result\");\n }\n return value;\n });\n },\n },\n };\n}\n";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export declare const STARTER_REVIEW_SCHEMA = "-- Apply with the platform migration owner, never from the HTTP process.\n-- One application database belongs to one explicitly provisioned project/tenant.\nCREATE TABLE public.starter_application (\n singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),\n project_id text NOT NULL CHECK (length(project_id) > 0),\n tenant_id text NOT NULL CHECK (length(tenant_id) > 0)\n);\nCREATE TABLE public.starter_reviews (\n id text PRIMARY KEY, owner_id text NOT NULL,\n state text NOT NULL CHECK (state IN ('draft','approved')),\n version integer NOT NULL CHECK (version > 0)\n);\nCREATE TABLE public.starter_members (\n subject text PRIMARY KEY, enabled boolean NOT NULL DEFAULT true,\n can_approve boolean NOT NULL DEFAULT false\n);\nALTER TABLE public.starter_application ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.starter_reviews ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.starter_members ENABLE ROW LEVEL SECURITY;\n";
|
|
2
|
-
export declare const STARTER_REVIEW_POSTGRES = "import {
|
|
3
|
-
export declare const STARTER_REVIEW_POSTGRES_TEST = "import { expect, test } from \"bun:test\";\nimport { createReviewPostgresAdapters } from \"../src/host/review-postgres\";\nimport type { ReviewStore } from \"../src/review/review\";\n\nconst identity = {\n issuer: \"https://identity.example.test\", audience: \"review-test\", clientId: \"review-client\",\n projectId: \"review-test\", jwksUrl: \"https://identity.example.test/jwks\",\n};\nfunction database(binding: unknown) {\n const queries: string[] = [];\n return {\n queries,\n async transaction<T>(run: (tx: { query(text: string): Promise<unknown> }) => Promise<T>): Promise<T> {\n return run({ async query(text) { queries.push(text); return binding; } });\n },\n };\n}\n\ntest(\"PostgreSQL host rejects missing or mismatched application ownership without creating data\", async () => {\n for (const binding of [[], [{ project_id: \"other\", tenant_id: \"review-test\" }],\n [{ project_id: \"review-test\", tenant_id: \"other\" }]]) {\n const db = database(binding);\n await expect(createReviewPostgresAdapters({ database: db, identity, tenantId: \"review-test\" }))\n .rejects.toThrow(\"binding mismatch\");\n expect(db.queries).toEqual([\"SELECT project_id,tenant_id FROM public.starter_application WHERE singleton\"]);\n }\n});\n\ntest(\"PostgreSQL host never falls back to demo identity or unbound business storage\", async () => {\n const db = database([{ project_id: \"review-test\", tenant_id: \"review-test\" }]);\n const adapters = await createReviewPostgresAdapters({ database: db, identity, tenantId: \"review-test\" });\n await expect(adapters.requestContext(new Request(\"http://localhost/reviews/health\")))\n .rejects.toMatchObject({ code: \"AUTHENTICATION_REQUIRED\" });\n const store = adapters.deps[\"dbClient\"] as ReviewStore;\n await expect(store.get(\"reviews\", \"example\")).rejects.toThrow(\"
|
|
2
|
+
export declare const STARTER_REVIEW_POSTGRES = "import { plaintextCommandInput } from \"@supacloud/commands\";\nimport { CommandError } from \"@supacloud/contracts\";\nimport { createPostgresCommandStore, type CommandDatabase, type CommandTransaction } from \"@supacloud/db\";\nimport {\n ApplicationError, createSchemaDecoder, createSupAuthRequestContext, requireTrustedIdentity,\n createTransactionalCommandGovernance,\n type CommandInvocation, type SupAuthContextOptions,\n} from \"@supacloud/elysia\";\nimport type { AppAdapters } from \"../application\";\nimport type { ReviewStore } from \"../review/review\";\nimport type { ReviewUploadPort } from \"../review/uploads\";\nimport { ApprovalInput, ReviewResult } from \"../review/contracts\";\n\nconst input = createSchemaDecoder(ApprovalInput, { normalize: false });\nconst result = createSchemaDecoder(ReviewResult, { normalize: false });\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\nfunction record(value: unknown): Record<string, unknown> {\n if (!isRecord(value)) throw new TypeError(\"Invalid review record\");\n return value;\n}\nfunction rows(value: unknown): Record<string, unknown>[] {\n if (!Array.isArray(value)) throw new TypeError(\"Invalid database result\");\n return value.map(record);\n}\nfunction isPublicConflict(value: unknown): boolean {\n return value instanceof ApplicationError && value.status === 409\n || isRecord(value) && value[\"expose\"] === true && value[\"status\"] === 409 && typeof value[\"code\"] === \"string\";\n}\nexport interface ReviewPostgresOptions {\n database: CommandDatabase;\n tenantId: string;\n identity: Omit<SupAuthContextOptions, \"resolveAccess\">;\n uploads?: ReviewUploadPort;\n // Only database effects here: they commit/rollback with the approval and receipt.\n afterApproved?(tx: CommandTransaction, id: string, version: number): Promise<void>;\n}\n\nexport async function createReviewPostgresAdapters(options: ReviewPostgresOptions): Promise<\n AppAdapters & { requestContext: ReturnType<typeof createSupAuthRequestContext> }\n> {\n const { database, tenantId, identity } = options;\n const projectId = identity.projectId;\n const afterApproved = options.afterApproved;\n if (!tenantId.trim()) throw new TypeError(\"Review tenantId is required\");\n const query = (text: string, parameters: readonly string[] = []) =>\n database.transaction(tx => tx.query(text, parameters));\n const binding = rows(await query(\n \"SELECT project_id,tenant_id FROM public.starter_application WHERE singleton\",\n ));\n if (binding.length !== 1 || binding[0]?.[\"project_id\"] !== projectId || binding[0]?.[\"tenant_id\"] !== tenantId) {\n throw new Error(\"Review database project/tenant binding mismatch\");\n }\n const requestContext = createSupAuthRequestContext({\n ...identity,\n async resolveAccess(who) {\n const found = rows(await query(\n \"SELECT m.can_approve FROM public.starter_members m,public.starter_application a \" +\n \"WHERE m.subject=$1 AND m.enabled AND a.singleton AND a.project_id=$2 AND a.tenant_id=$3\",\n [who.subject, projectId, tenantId],\n ));\n return found.length === 1 ? { projectId, tenantId,\n permissions: found[0]?.[\"can_approve\"] === true ? [\"review.approve\"] : [],\n } : null;\n },\n });\n const transaction = () => commandBinding.transaction();\n const store: ReviewStore = {\n ...options.uploads,\n async get(table, id) {\n if (table !== \"reviews\") throw new Error(\"Unsupported review table\");\n return rows(await transaction().query(\n \"SELECT state,version FROM public.starter_reviews WHERE id=$1 FOR UPDATE\", [id],\n ))[0];\n },\n async set(table, id, value) {\n if (table !== \"reviews\") throw new Error(\"Unsupported review table\");\n const review = result(value);\n const updated = rows(await transaction().query(\n \"UPDATE public.starter_reviews SET state=$2,version=$3 WHERE id=$1 RETURNING id\",\n [id, review.state, review.version],\n ));\n if (updated.length !== 1) throw new Error(\"Review write did not update one record\");\n await afterApproved?.(transaction(), id, review.version);\n },\n };\n function actor(invocation: CommandInvocation): string {\n const who = requireTrustedIdentity(invocation.requestContext);\n const access = record(record(invocation.requestContext)[\"access\"]);\n if (access[\"projectId\"] !== projectId || access[\"tenantId\"] !== tenantId\n || !Array.isArray(access[\"permissions\"]) || !access[\"permissions\"].includes(\"review.approve\")) {\n throw new ApplicationError(\"Command permission denied\", { status: 403, code: \"REVIEW_PERMISSION_DENIED\" });\n }\n return who.subject;\n }\n const commandBinding = createTransactionalCommandGovernance({\n permission: \"review.approve\",\n identity: invocation => ({ actorId: actor(invocation), tenantId }),\n input: invocation => ({\n ...record(invocation.input.body), id: invocation.input.params[\"id\"],\n }),\n rejection: error => isPublicConflict(error) ? new CommandError(\"COMMAND_IDEMPOTENCY_CONFLICT\") : undefined,\n definition: {\n name: \"review.approve\", store: createPostgresCommandStore(database),\n inputCodec: plaintextCommandInput, input, result,\n async authorize(who, request, tx) {\n const found = rows(await tx.query(\n \"SELECT r.id FROM public.starter_reviews r JOIN public.starter_members m ON m.subject=r.owner_id \" +\n \"CROSS JOIN public.starter_application a \" +\n \"WHERE r.id=$1 AND r.owner_id=$2 AND m.enabled AND m.can_approve \" +\n \"AND a.singleton AND a.project_id=$3 AND a.tenant_id=$4 FOR UPDATE OF r,m FOR SHARE OF a\",\n [request.id, who.actorId, projectId, who.tenantId],\n ));\n return found.length === 1 ? \"allow\" : \"deny\";\n },\n audit: { event: \"review.approved\", details: request => ({ reviewId: request.id }) },\n },\n });\n return { deps: { dbClient: store }, requestContext, commandGovernance: commandBinding.governance };\n}\n";
|
|
3
|
+
export declare const STARTER_REVIEW_POSTGRES_TEST = "import { expect, test } from \"bun:test\";\nimport { createReviewPostgresAdapters } from \"../src/host/review-postgres\";\nimport type { ReviewStore } from \"../src/review/review\";\n\nconst identity = {\n issuer: \"https://identity.example.test\", audience: \"review-test\", clientId: \"review-client\",\n projectId: \"review-test\", jwksUrl: \"https://identity.example.test/jwks\",\n};\nfunction database(binding: unknown) {\n const queries: string[] = [];\n return {\n queries,\n async transaction<T>(run: (tx: { query(text: string): Promise<unknown> }) => Promise<T>): Promise<T> {\n return run({ async query(text) { queries.push(text); return binding; } });\n },\n };\n}\n\ntest(\"PostgreSQL host rejects missing or mismatched application ownership without creating data\", async () => {\n for (const binding of [[], [{ project_id: \"other\", tenant_id: \"review-test\" }],\n [{ project_id: \"review-test\", tenant_id: \"other\" }]]) {\n const db = database(binding);\n await expect(createReviewPostgresAdapters({ database: db, identity, tenantId: \"review-test\" }))\n .rejects.toThrow(\"binding mismatch\");\n expect(db.queries).toEqual([\"SELECT project_id,tenant_id FROM public.starter_application WHERE singleton\"]);\n }\n});\n\ntest(\"PostgreSQL host never falls back to demo identity or unbound business storage\", async () => {\n const db = database([{ project_id: \"review-test\", tenant_id: \"review-test\" }]);\n const adapters = await createReviewPostgresAdapters({ database: db, identity, tenantId: \"review-test\" });\n await expect(adapters.requestContext(new Request(\"http://localhost/reviews/health\")))\n .rejects.toMatchObject({ code: \"AUTHENTICATION_REQUIRED\" });\n const store = adapters.deps[\"dbClient\"] as ReviewStore;\n await expect(store.get(\"reviews\", \"example\")).rejects.toThrow(\"not active\");\n await expect(store.set(\"reviews\", \"example\", { state: \"approved\", version: 2 }))\n .rejects.toThrow(\"not active\");\n expect(db.queries).toHaveLength(1);\n});\n";
|
|
4
4
|
export declare const STARTER_REVIEW_DELIVERY_HOST = "import { SQL } from \"bun\";\nimport { createClient } from \"@supabase/supabase-js\";\nimport { createBunCommandDatabase } from \"@supacloud/db/bun\";\nimport { createApplication, type CompiledModule } from \"@supacloud/elysia\";\nimport { createReviewPostgresAdapters } from \"./host/review-postgres\";\nimport { createReviewAttachmentAdapters } from \"./host/review-attachments\";\nimport { createReviewUploadAdapters } from \"./host/review-uploads\";\n\nfunction required(name: string): string {\n const value = process.env[name];\n if (!value?.trim()) throw new Error(\"Missing required host setting: \" + name);\n return value;\n}\n\nfunction databaseConnection(): SQL.Options {\n const socket = process.env[\"DATABASE_SOCKET_PATH\"];\n if (socket && process.env[\"DATABASE_URL\"]) throw new Error(\"Conflicting database connection settings\");\n if (socket) {\n return { adapter: \"postgres\", path: socket, database: required(\"DATABASE_NAME\"), username: required(\"DATABASE_USER\") };\n }\n const url = new URL(required(\"DATABASE_URL\"));\n if (url.protocol !== \"postgres:\" && url.protocol !== \"postgresql:\") throw new Error(\"Invalid database URL\");\n return {\n adapter: \"postgres\", url: url.href, hostname: url.hostname.replace(/^\\[|\\]$/g, \"\"), port: Number(url.port || 5432),\n username: decodeURIComponent(url.username), password: decodeURIComponent(url.password),\n database: decodeURIComponent(url.pathname.slice(1)),\n };\n}\n\nexport async function createDeliveryApplication(modules: CompiledModule[], lifecycle: { signal: AbortSignal }) {\n lifecycle.signal.throwIfAborted();\n const connection = databaseConnection();\n const attachments = process.env[\"REVIEW_ATTACHMENTS\"];\n if (attachments !== undefined && attachments !== \"enabled\") throw new Error(\"Invalid attachment configuration\");\n const settings = {\n tenantId: required(\"APP_TENANT_ID\"),\n identity: {\n issuer: required(\"SUPAUTH_ISSUER\"), audience: required(\"SUPAUTH_AUDIENCE\"),\n clientId: required(\"SUPAUTH_CLIENT_ID\"), projectId: required(\"SUPACLOUD_PROJECT_ID\"),\n jwksUrl: required(\"SUPAUTH_JWKS_URL\"),\n },\n };\n const pool = new SQL({ ...connection, max: 12, connectionTimeout: 5 });\n let closing: Promise<void> | undefined;\n const close = () => closing ??= pool.close({ timeout: 5 });\n let stage: \"attachments\" | \"uploads\" | \"postgres\" | \"application\" = \"attachments\";\n try {\n const database = createBunCommandDatabase(pool);\n const attachmentOptions = attachments === \"enabled\" ? {\n database, tenantId: settings.tenantId, projectId: settings.identity.projectId,\n service: createClient(required(\"SUPACLOUD_URL\"), required(\"SUPACLOUD_SERVICE_ROLE_KEY\"), {\n auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },\n }),\n } : undefined;\n const durable = attachmentOptions ? await createReviewAttachmentAdapters(attachmentOptions) : undefined;\n stage = \"uploads\";\n const uploads = attachmentOptions ? await createReviewUploadAdapters(attachmentOptions) : undefined;\n stage = \"postgres\";\n const adapters = await createReviewPostgresAdapters({\n database, tenantId: settings.tenantId, identity: settings.identity,\n ...(uploads ? { uploads } : {}),\n ...(durable ? { afterApproved: durable.enqueue } : {}),\n });\n lifecycle.signal.throwIfAborted();\n stage = \"application\";\n const app = createApplication({ name: \"review\", modules, ...adapters });\n return { fetch: (request: Request) => app.handle(request), close };\n } catch (error) {\n await close();\n const code = error !== null && typeof error === \"object\" && \"code\" in error\n && typeof error.code === \"string\" && /^[A-Z0-9_]{1,64}$/.test(error.code) ? error.code : \"UNKNOWN\";\n console.error(JSON.stringify({ event: \"review-host-initialization-failed\", stage, code }));\n // Database/identity errors can contain connection details; never echo the underlying cause.\n throw new Error(\"Review host initialization failed\");\n }\n}\n";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const STARTER_REVIEW_QUERY = "import type { SupabaseClient } from \"@supabase/supabase-js\";\nimport type { ReviewRow } from \"../../db/schema\";\n\n// This read projection uses SQL column names shared with the Drizzle source.\n// Do not expose camelCase Drizzle property names as PostgREST column names.\nexport type ReviewSummary = Pick<ReviewRow, \"id\" | \"state\" | \"version\">;\nexport type ReviewReadDatabase = {\n public: {\n Tables: {\n starter_reviews: {\n Row: ReviewSummary;\n Insert: never;\n Update: never;\n Relationships: [];\n };\n };\n Views: Record<never, never>;\n Functions: Record<never, never>;\n };\n};\n\nexport interface ReviewPageOptions {\n page?: number;\n pageSize?: number;\n signal?: AbortSignal;\n}\n\n// Reuse the application's user-scoped client. Identity verification and row\n// authorization belong to the gateway and database, never a caller-supplied owner.\nexport async function listReviews(\n client: SupabaseClient<ReviewReadDatabase>,\n { page = 1, pageSize = 25, signal }: ReviewPageOptions = {},\n) {\n if (!Number.isSafeInteger(page) || page < 1\n || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100\n || !Number.isSafeInteger(page * pageSize)) {\n throw new RangeError(\"Invalid review page: page >= 1 and pageSize between 1 and 100 are required\");\n }\n signal?.throwIfAborted();\n const start = (page - 1) * pageSize;\n const query = client.from(\"starter_reviews\")\n .select(\"id,state,version\", { count: \"exact\" })\n .order(\"id\", { ascending: true })\n .range(start, start + pageSize - 1);\n const { data, error, count } = await (signal ? query.abortSignal(signal) : query);\n if (error) throw error;\n if (data === null || count === null) throw new Error(\"Incomplete review page response\");\n return { data, total: count, page, pageSize, hasNextPage: start + data.length < count };\n}\n";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const STARTER_REVIEW_JOB = "import { DB_CLIENT, Inject, Injectable, Job } from \"@supacloud/app\";\nimport { ApplicationError } from \"@supacloud/elysia\";\nimport { t } from \"elysia\";\nimport type { Static } from \"elysia/type\";\n\nexport const AttachmentInput = t.Object({\n reviewId: t.String({ minLength: 1, maxLength: 100 }),\n version: t.Integer({ minimum: 2 }),\n artifactId: t.String({ format: \"uuid\" }),\n}, { additionalProperties: false });\n\nexport const AttachmentResult = t.Object({\n ...AttachmentInput.properties,\n sha256: t.String({ pattern: \"^[a-f0-9]{64}$\" }),\n bytes: t.Integer({ minimum: 1, maximum: 1048576 }),\n}, { additionalProperties: false });\n\nexport type ReviewAttachmentInput = Static<typeof AttachmentInput>;\nexport type ReviewAttachmentResult = Static<typeof AttachmentResult>;\n\nexport interface ReviewAttachmentStore {\n // Resolve the immutable artifact under the worker's own authorization and current review revision.\n readAttachment(input: ReviewAttachmentInput): Promise<{ body: Uint8Array; sha256: string }>;\n // Revalidate revision/ownership and persist idempotently in a transaction; return the durable result.\n recordAttachment(input: ReviewAttachmentInput, result: ReviewAttachmentResult): Promise<ReviewAttachmentResult>;\n}\n\n@Injectable({ scope: \"job\" })\n@Job({\n name: \"review.verify-attachment\", mode: \"task\", idempotency: \"required\",\n timeoutSec: 30, maxAttempts: 3, input: AttachmentInput, output: AttachmentResult,\n})\nexport class VerifyReviewAttachment {\n constructor(@Inject(DB_CLIENT) private readonly store: ReviewAttachmentStore) {}\n\n async run(input: ReviewAttachmentInput): Promise<ReviewAttachmentResult> {\n const source = await this.store.readAttachment(input);\n if (source.body.byteLength < 1 || source.body.byteLength > 1048576) {\n throw new ApplicationError(\"Attachment size is outside the supported range\", { code: \"ATTACHMENT_SIZE_INVALID\" });\n }\n const digest = await crypto.subtle.digest(\"SHA-256\", new Uint8Array(source.body));\n const sha256 = Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, \"0\")).join(\"\");\n if (sha256 !== source.sha256) {\n throw new ApplicationError(\"Attachment integrity check failed\", { code: \"ATTACHMENT_INTEGRITY_FAILED\" });\n }\n return this.store.recordAttachment(input, { ...input, sha256, bytes: source.body.byteLength });\n }\n}\n";
|
|
2
|
-
export declare const STARTER_REVIEW_JOB_TEST = "import { expect, test } from \"bun:test\";\nimport { VerifyReviewAttachment, type ReviewAttachmentResult } from \"../src/review/attachment\";\n\nconst input = { reviewId: \"review\", version: 2, artifactId: \"11111111-1111-4111-8111-111111111111\" };\nconst body = new TextEncoder().encode(\"review attachment\");\nconst sha256 = Array.from(new Uint8Array(await crypto.subtle.digest(\"SHA-256\", body)),\n byte => byte.toString(16).padStart(2, \"0\")).join(\"\");\n\ntest(\"attachment job awaits the authorized reader and the committed result\", async () => {\n let recorded: ReviewAttachmentResult | undefined;\n const job = new VerifyReviewAttachment({\n async readAttachment() { return { body, sha256 }; },\n async recordAttachment(_input, result) { await Promise.resolve(); recorded = result; return result; },\n });\n expect(await job.run(input)).toEqual({ ...input, sha256, bytes: body.byteLength });\n expect(recorded).toEqual({ ...input, sha256, bytes: body.byteLength });\n});\n\ntest(\"missing authorization, corrupted bytes and persistence failures are not successful jobs\", async () => {\n let records = 0;\n for (const readAttachment of [\n async () => { throw new Error(\"Access denied\"); },\n async () => ({ body, sha256: \"0\".repeat(64) }),\n async () => ({ body: new Uint8Array(), sha256 }),\n async () => ({ body: new Uint8Array(1048577), sha256 }),\n ]) {\n const job = new VerifyReviewAttachment({\n readAttachment, async recordAttachment(_input, result) { records++; return result; },\n });\n await expect(job.run(input)).rejects.toThrow();\n }\n expect(records).toBe(0);\n await expect(new VerifyReviewAttachment({\n async readAttachment() { return { body, sha256 }; },\n async recordAttachment() { throw new Error(\"Commit failed\"); },\n }).run(input)).rejects.toThrow(\"Commit failed\");\n});\n";
|
|
1
|
+
export declare const STARTER_REVIEW_JOB = "import { DB_CLIENT, Inject, Injectable, Job } from \"@supacloud/app\";\nimport { ApplicationError } from \"@supacloud/elysia\";\nimport { t } from \"elysia\";\nimport type { Static } from \"elysia/type\";\n\nexport const AttachmentInput = t.Object({\n reviewId: t.String({ minLength: 1, maxLength: 100 }),\n version: t.Integer({ minimum: 2 }),\n artifactId: t.String({ format: \"uuid\" }),\n}, { additionalProperties: false });\n\nexport const AttachmentResult = t.Object({\n ...AttachmentInput.properties,\n sha256: t.String({ pattern: \"^[a-f0-9]{64}$\" }),\n bytes: t.Integer({ minimum: 1, maximum: 1048576 }),\n}, { additionalProperties: false });\n\nexport type ReviewAttachmentInput = Static<typeof AttachmentInput>;\nexport type ReviewAttachmentResult = Static<typeof AttachmentResult>;\n\nexport interface ReviewAttachmentStore {\n // Reauthorize before returning a committed result; null means execution is still needed.\n readResult(input: ReviewAttachmentInput): Promise<ReviewAttachmentResult | null>;\n // Resolve the immutable artifact under the worker's own authorization and current review revision.\n readAttachment(input: ReviewAttachmentInput): Promise<{ body: Uint8Array; sha256: string }>;\n // Revalidate revision/ownership and persist idempotently in a transaction; return the durable result.\n recordAttachment(input: ReviewAttachmentInput, result: ReviewAttachmentResult): Promise<ReviewAttachmentResult>;\n}\n\n@Injectable({ scope: \"job\" })\n@Job({\n name: \"review.verify-attachment\", mode: \"task\", idempotency: \"required\",\n timeoutSec: 30, maxAttempts: 3, input: AttachmentInput, output: AttachmentResult,\n})\nexport class VerifyReviewAttachment {\n constructor(@Inject(DB_CLIENT) private readonly store: ReviewAttachmentStore) {}\n\n async run(input: ReviewAttachmentInput): Promise<ReviewAttachmentResult> {\n const committed = await this.store.readResult(input);\n if (committed !== null) return committed;\n const source = await this.store.readAttachment(input);\n if (source.body.byteLength < 1 || source.body.byteLength > 1048576) {\n throw new ApplicationError(\"Attachment size is outside the supported range\", { code: \"ATTACHMENT_SIZE_INVALID\" });\n }\n const digest = await crypto.subtle.digest(\"SHA-256\", new Uint8Array(source.body));\n const sha256 = Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, \"0\")).join(\"\");\n if (sha256 !== source.sha256) {\n throw new ApplicationError(\"Attachment integrity check failed\", { code: \"ATTACHMENT_INTEGRITY_FAILED\" });\n }\n return this.store.recordAttachment(input, { ...input, sha256, bytes: source.body.byteLength });\n }\n}\n";
|
|
2
|
+
export declare const STARTER_REVIEW_JOB_TEST = "import { expect, test } from \"bun:test\";\nimport { VerifyReviewAttachment, type ReviewAttachmentResult } from \"../src/review/attachment\";\n\nconst input = { reviewId: \"review\", version: 2, artifactId: \"11111111-1111-4111-8111-111111111111\" };\nconst body = new TextEncoder().encode(\"review attachment\");\nconst sha256 = Array.from(new Uint8Array(await crypto.subtle.digest(\"SHA-256\", body)),\n byte => byte.toString(16).padStart(2, \"0\")).join(\"\");\n\ntest(\"attachment job awaits the authorized reader and the committed result\", async () => {\n let recorded: ReviewAttachmentResult | undefined;\n const job = new VerifyReviewAttachment({\n async readResult() { return null; },\n async readAttachment() { return { body, sha256 }; },\n async recordAttachment(_input, result) { await Promise.resolve(); recorded = result; return result; },\n });\n expect(await job.run(input)).toEqual({ ...input, sha256, bytes: body.byteLength });\n expect(recorded).toEqual({ ...input, sha256, bytes: body.byteLength });\n});\n\ntest(\"missing authorization, corrupted bytes and persistence failures are not successful jobs\", async () => {\n let records = 0;\n for (const readAttachment of [\n async () => { throw new Error(\"Access denied\"); },\n async () => ({ body, sha256: \"0\".repeat(64) }),\n async () => ({ body: new Uint8Array(), sha256 }),\n async () => ({ body: new Uint8Array(1048577), sha256 }),\n ]) {\n const job = new VerifyReviewAttachment({\n async readResult() { return null; },\n readAttachment, async recordAttachment(_input, result) { records++; return result; },\n });\n await expect(job.run(input)).rejects.toThrow();\n }\n expect(records).toBe(0);\n await expect(new VerifyReviewAttachment({\n async readResult() { return null; },\n async readAttachment() { return { body, sha256 }; },\n async recordAttachment() { throw new Error(\"Commit failed\"); },\n }).run(input)).rejects.toThrow(\"Commit failed\");\n});\n\ntest(\"a reauthorized committed result avoids another download or write\", async () => {\n const result = { ...input, sha256, bytes: body.byteLength };\n const job = new VerifyReviewAttachment({\n async readResult() { return result; },\n async readAttachment() { throw new Error(\"Must not download again\"); },\n async recordAttachment() { throw new Error(\"Must not write again\"); },\n });\n expect(await job.run(input)).toEqual(result);\n await expect(new VerifyReviewAttachment({\n async readResult() { throw new Error(\"Access revoked\"); },\n async readAttachment() { throw new Error(\"Must not download\"); },\n async recordAttachment() { throw new Error(\"Must not write\"); },\n }).run(input)).rejects.toThrow(\"Access revoked\");\n});\n";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supacloud/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.101.2",
|
|
4
4
|
"description": "Project-scoped CLI for SupaCloud users",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -52,21 +52,21 @@
|
|
|
52
52
|
"directory": "packages/cli"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@supacloud/compiler": "^0.46.
|
|
56
|
-
"@supacloud/delivery": "0.18.
|
|
57
|
-
"@supacloud/db": "0.17.
|
|
58
|
-
"typebox": "1.3.
|
|
55
|
+
"@supacloud/compiler": "^0.46.4",
|
|
56
|
+
"@supacloud/delivery": "0.18.1",
|
|
57
|
+
"@supacloud/db": "0.17.1",
|
|
58
|
+
"typebox": "1.3.37"
|
|
59
59
|
},
|
|
60
60
|
"overrides": {
|
|
61
|
-
"@supacloud/contracts": "0.
|
|
62
|
-
"@supacloud/delivery": "0.18.
|
|
63
|
-
"@graphql-tools/utils": "^12.0.
|
|
61
|
+
"@supacloud/contracts": "0.10.0",
|
|
62
|
+
"@supacloud/delivery": "0.18.1",
|
|
63
|
+
"@graphql-tools/utils": "^12.0.4"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
|
-
"@supacloud/elysia": "
|
|
66
|
+
"@supacloud/elysia": "0.31.1",
|
|
67
67
|
"@types/bun": "^1.4.3",
|
|
68
|
-
"effect": "4.0.
|
|
69
|
-
"elysia": "2.0.0-beta.
|
|
68
|
+
"effect": "4.0.5",
|
|
69
|
+
"elysia": "2.0.0-beta.32",
|
|
70
70
|
"typescript": "^7.0.2"
|
|
71
71
|
}
|
|
72
72
|
}
|