@drkmttr/adhere 0.7.3 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +177 -37
- package/package.json +12 -12
- package/presets/alchemy/apis/match-remote-errors-by-tag.md +15 -0
- package/presets/alchemy/apis/schemas-at-trust-boundaries.md +18 -0
- package/presets/alchemy/data/commit-migrations-then-deploy.md +15 -0
- package/presets/alchemy/data/hyperdrive-direct-origin.md +14 -0
- package/presets/alchemy/data/migrations-use-direct-url.md +11 -0
- package/presets/alchemy/data/never-disable-tls-verification.md +16 -0
- package/presets/alchemy/data/parameterized-sql.md +13 -0
- package/presets/alchemy/data/private-endpoint-at-runtime.md +12 -0
- package/presets/alchemy/data/single-migration-owner.md +13 -0
- package/presets/alchemy/durable/idempotent-event-handlers.md +15 -0
- package/presets/alchemy/durable/io-inside-steps.md +17 -0
- package/presets/alchemy/durable/no-self-triggering-bucket-writes.md +15 -0
- package/presets/alchemy/durable/stable-step-names.md +11 -0
- package/presets/alchemy/durable/webhook-state-is-truth.md +14 -0
- package/presets/alchemy/providers/diff-guards-unresolved-props.md +16 -0
- package/presets/alchemy/providers/idempotent-delete.md +14 -0
- package/presets/alchemy/providers/lazy-layer-construction.md +14 -0
- package/presets/alchemy/providers/reconcile-from-observed-state.md +20 -0
- package/presets/alchemy/resources/anchor-paths-with-import-meta.md +14 -0
- package/presets/alchemy/resources/idempotent-actions-and-seeds.md +19 -0
- package/presets/alchemy/resources/previews-reference-shared-resources.md +15 -0
- package/presets/alchemy/resources/protect-irreplaceable-data.md +12 -0
- package/presets/alchemy/resources/remote-state-for-ci.md +22 -0
- package/presets/alchemy/resources/stable-logical-ids.md +13 -0
- package/presets/alchemy/resources/stage-unique-physical-names.md +15 -0
- package/presets/alchemy/runtime/bind-tag-not-implementation.md +27 -0
- package/presets/alchemy/runtime/deterministic-config-defaults.md +13 -0
- package/presets/alchemy/runtime/disposables-in-request-scope.md +27 -0
- package/presets/alchemy/runtime/lambda-no-post-response-work.md +19 -0
- package/presets/alchemy/runtime/no-per-request-work-at-construction.md +24 -0
- package/presets/alchemy/runtime/resolve-config-and-bindings-at-construction.md +31 -0
- package/presets/alchemy/secrets/let-alchemy-mint-infra-secrets.md +13 -0
- package/presets/alchemy/secrets/never-logged-returned-or-output.md +15 -0
- package/presets/alchemy/secrets/none-in-browser-env.md +15 -0
- package/presets/alchemy/secrets/redacted-until-use.md +19 -0
- package/presets/alchemy/secrets/unwrap-before-compare-or-send.md +17 -0
- package/presets/alchemy/security/authorize-public-endpoints.md +20 -0
- package/presets/alchemy/security/github-secret-not-variable.md +12 -0
- package/presets/alchemy/security/identity-from-verified-session.md +14 -0
- package/presets/alchemy/security/keep-auth-protections-on.md +19 -0
- package/presets/alchemy/security/least-privilege-bindings.md +13 -0
- package/presets/alchemy/security/least-privilege-ci-credentials.md +24 -0
- package/presets/alchemy/security/lookup-failure-is-not-anonymous.md +15 -0
- package/presets/effect/basics/external-calls-are-resilient.md +3 -3
- package/presets/effect/config/tests-provide-values-directly.md +3 -3
- package/presets/effect/config/validate-with-schema.md +13 -2
- package/presets/effect/data/variants-are-tagged-unions.md +1 -1
- package/presets/effect/services/dependencies-through-layers.md +42 -0
- package/presets/effect/services/operations-have-no-requirements.md +46 -0
- package/presets/effect/services/test-layers-are-in-memory.md +3 -3
- package/presets/effect/testing/test-clock-for-time.md +1 -1
- package/skills/adhere/SKILL.md +3 -2
- package/src/cli.ts +42 -16
- package/src/config.ts +12 -2
- package/src/excerpt.ts +255 -0
- package/src/highlight.ts +82 -20
- package/src/init.ts +10 -4
- package/src/main.ts +35 -4
- package/src/mechanical.ts +89 -0
- package/src/presets.ts +45 -8
- package/src/rules.ts +2 -0
- package/src/services/AdhereConfig.ts +18 -20
- package/src/services/AuditCache.ts +272 -23
- package/src/services/Jev.http.ts +66 -12
- package/src/services/Jev.ts +66 -6
- package/src/services/SourceWalker.ts +11 -4
- package/src/wording.ts +90 -0
- package/src/workflows/audit.ts +169 -26
- package/src/workflows/format.ts +35 -18
- package/presets/effect/basics/fn-for-named-effects.md +0 -19
- package/presets/effect/cli/handlers-delegate.md +0 -24
- package/presets/effect/data/decode-json-with-schema.md +0 -18
- package/presets/effect/data/records-are-schema-classes.md +0 -23
- package/presets/effect/errors/domain-errors-are-tagged.md +0 -19
- package/presets/effect/platform/os-access-through-platform-services.md +0 -26
- package/presets/effect/services/methods-have-no-requirements.md +0 -20
- package/presets/effect/services/provide-at-entry.md +0 -23
- package/presets/effect/testing/test-random.md +0 -22
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: An Action body and a SQL file listed in importFiles must be safe to run again, using IF NOT EXISTS, ON CONFLICT, or upserts, never one-shot inserts or calls that duplicate data or fail when an interrupted deploy retries them.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
```ts must
|
|
6
|
+
export const Migrate = Alchemy.Action("Migrate", Effect.fn(function* () {
|
|
7
|
+
const db = yield* D1Client;
|
|
8
|
+
yield* db.exec("CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, name TEXT)");
|
|
9
|
+
yield* db.exec("INSERT INTO users (id, name) VALUES ('admin', 'Admin') ON CONFLICT (id) DO NOTHING");
|
|
10
|
+
}));
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```ts never
|
|
14
|
+
export const Seed = Alchemy.Action("Seed", Effect.fn(function* () {
|
|
15
|
+
const db = yield* D1Client;
|
|
16
|
+
yield* db.exec("CREATE TABLE users (id TEXT, name TEXT)");
|
|
17
|
+
yield* db.exec("INSERT INTO users VALUES ('admin', 'Admin')"); // a second admin on every retry
|
|
18
|
+
}));
|
|
19
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Preview stages should reference a long-lived database or cluster with ref and create only cheap per-stage pieces such as a branch, and should not provision a whole project or cluster for every preview.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
```ts should
|
|
6
|
+
const project = stage.startsWith("pr-")
|
|
7
|
+
? yield* Neon.Project.ref("AppDb", { stage: "staging" })
|
|
8
|
+
: yield* Neon.Project("AppDb", { region: "aws-us-east-1" });
|
|
9
|
+
const branch = yield* Neon.Branch("Branch", { project });
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```ts should not
|
|
13
|
+
const project = yield* Neon.Project("AppDb", { region: "aws-us-east-1" }); // a new project per PR
|
|
14
|
+
const branch = yield* Neon.Branch("Branch", { project });
|
|
15
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A bucket or database holding user or business data should be retained with RemovalPolicy.retain(), at least in production, and forceDestroy should be set only on buckets whose contents are disposable; irreplaceable data should not depend on the default destroy policy.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
```ts should
|
|
6
|
+
const uploads = yield* Cloudflare.R2.Bucket("Uploads").pipe(RemovalPolicy.retain(stage === "prod"));
|
|
7
|
+
const cache = yield* Cloudflare.R2.Bucket("Cache", { forceDestroy: true });
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```ts should not
|
|
11
|
+
const userUploads = yield* Cloudflare.R2.Bucket("UserUploads", { forceDestroy: true });
|
|
12
|
+
```
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A stack deployed from CI or by more than one machine must use a shared state store such as Cloudflare.state() or AWS.state(), never Alchemy.localState(), which lives on one machine's disk where CI runners cannot see it.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
```ts must
|
|
6
|
+
export default Alchemy.Stack("App", { providers: Cloudflare.providers(), state: Cloudflare.state() },
|
|
7
|
+
Effect.gen(function* () {
|
|
8
|
+
const api = yield* Api;
|
|
9
|
+
return { url: api.url };
|
|
10
|
+
}),
|
|
11
|
+
);
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```ts never
|
|
15
|
+
// deployed by .github/workflows/deploy.yml
|
|
16
|
+
export default Alchemy.Stack("App", { providers: Cloudflare.providers(), state: Alchemy.localState() },
|
|
17
|
+
Effect.gen(function* () {
|
|
18
|
+
const api = yield* Api;
|
|
19
|
+
return { url: api.url };
|
|
20
|
+
}),
|
|
21
|
+
);
|
|
22
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A resource's logical ID, its first argument, must stay the same from one deploy to the next, never built from timestamps, random values, UUIDs, or commit SHAs, because a new logical ID replaces the resource and deletes the old one with its data.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
```ts must
|
|
6
|
+
const uploads = yield* Cloudflare.R2.Bucket("Uploads");
|
|
7
|
+
const jobs = yield* AWS.SQS.Queue("Jobs");
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```ts never
|
|
11
|
+
const uploads = yield* Cloudflare.R2.Bucket(`Uploads-${Date.now()}`);
|
|
12
|
+
const db = yield* Neon.Project(`Db-${process.env.GITHUB_SHA}`);
|
|
13
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: In a stack deployed to several stages, a resource whose physical name is global to an account or org should omit name, so alchemy derives one per stage, or derive it from the stage, and should not hardcode a single name every stage shares.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
```ts should
|
|
6
|
+
const traces = yield* Axiom.Dataset("Traces", Stack.useSync(({ stage }) => ({
|
|
7
|
+
name: `${stage}-traces`,
|
|
8
|
+
kind: "otel:traces:v1" as const,
|
|
9
|
+
})));
|
|
10
|
+
const branch = yield* Neon.Branch("Preview", { project });
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```ts should not
|
|
14
|
+
const traces = yield* Axiom.Dataset("Traces", { name: "app-traces", kind: "otel:traces:v1" });
|
|
15
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: When a runtime is split into a Tag class and a make Layer, other runtimes must import and bind only the Tag, and only the Stack provides the Layer; another Worker or Function must never import or provide that implementation Layer.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
```ts must
|
|
6
|
+
// src/Admin.ts
|
|
7
|
+
import { Api } from "./Api.ts";
|
|
8
|
+
|
|
9
|
+
export default Cloudflare.Worker("Admin", { main: import.meta.url },
|
|
10
|
+
Effect.gen(function* () {
|
|
11
|
+
const api = yield* Cloudflare.Workers.bindWorker(Api);
|
|
12
|
+
return { fetch: Effect.gen(function* () { return yield* api.fetch(yield* HttpServerRequest.HttpServerRequest); }) };
|
|
13
|
+
}),
|
|
14
|
+
);
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```ts never
|
|
18
|
+
// src/Admin.ts
|
|
19
|
+
import ApiLive, { Api } from "./Api.ts";
|
|
20
|
+
|
|
21
|
+
export default Cloudflare.Worker("Admin", { main: import.meta.url },
|
|
22
|
+
Effect.gen(function* () {
|
|
23
|
+
const api = yield* Cloudflare.Workers.bindWorker(Api);
|
|
24
|
+
return { fetch: forwardTo(api) };
|
|
25
|
+
}).pipe(Effect.provide(ApiLive)), // ships a second copy of Api's runtime
|
|
26
|
+
);
|
|
27
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A Config default or fallback used by a runtime must be deterministic, because it is evaluated at deploy time and at runtime, never computed from the clock, randomness, or per-process values.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
```ts must
|
|
6
|
+
const port = yield* Config.Number("PORT").pipe(Config.withDefault(3000));
|
|
7
|
+
const region = yield* Config.String("REGION").pipe(Config.withDefault("us-east-1"));
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```ts never
|
|
11
|
+
const instanceId = yield* Config.String("INSTANCE_ID").pipe(Config.withDefault(crypto.randomUUID()));
|
|
12
|
+
const startedAt = yield* Config.String("STARTED_AT").pipe(Config.withDefault(new Date().toISOString()));
|
|
13
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Connections, pools, sockets, and anything else with a finalizer must be acquired inside a handler or method, where the event's scope releases them, never at module scope or in a runtime's construction Effect, whose finalizers may never run.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
```ts must
|
|
6
|
+
Effect.gen(function* () {
|
|
7
|
+
const url = yield* Config.Redacted("DATABASE_URL");
|
|
8
|
+
return {
|
|
9
|
+
fetch: Effect.gen(function* () {
|
|
10
|
+
const pool = yield* Effect.acquireRelease(
|
|
11
|
+
Effect.sync(() => new Pool({ connectionString: Redacted.value(url), max: 1 })),
|
|
12
|
+
(pool) => Effect.promise(() => pool.end()),
|
|
13
|
+
);
|
|
14
|
+
const { rows } = yield* Effect.promise(() => pool.query("select id from users"));
|
|
15
|
+
return yield* HttpServerResponse.json(rows);
|
|
16
|
+
}),
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```ts never
|
|
22
|
+
Effect.gen(function* () {
|
|
23
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL }); // one pool for the isolate
|
|
24
|
+
yield* Effect.addFinalizer(() => Effect.promise(() => pool.end())); // workerd never runs this
|
|
25
|
+
return { fetch: handlerUsing(pool) };
|
|
26
|
+
});
|
|
27
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: In a Lambda handler, anything that must not be lost must be written durably, to a queue or a table, inside the handler before it responds, never left to a dangling promise, a forked fiber, or a finalizer, because Lambda freezes the instance after the response.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
```ts must
|
|
6
|
+
fetch: Effect.gen(function* () {
|
|
7
|
+
const event = yield* HttpServerRequest.schemaBodyJson(AuditEvent);
|
|
8
|
+
yield* sendMessage({ MessageBody: JSON.stringify(event) });
|
|
9
|
+
return HttpServerResponse.empty({ status: 202 });
|
|
10
|
+
}),
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```ts never
|
|
14
|
+
fetch: Effect.gen(function* () {
|
|
15
|
+
const event = yield* HttpServerRequest.schemaBodyJson(AuditEvent);
|
|
16
|
+
void fetch(AUDIT_URL, { method: "POST", body: JSON.stringify(event) }); // may never run
|
|
17
|
+
return HttpServerResponse.empty({ status: 202 });
|
|
18
|
+
}),
|
|
19
|
+
```
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A runtime's construction Effect runs at deploy time and again at cold start, so it must only build resources, bindings, layers, and handler groups, never do per-request work such as queries, writes, bound calls, or starting consumers.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
```ts must
|
|
6
|
+
Effect.gen(function* () {
|
|
7
|
+
const table = yield* AWS.DynamoDB.Table("Jobs", { partitionKey: "id", attributes: { id: "S" } });
|
|
8
|
+
const putItem = yield* AWS.DynamoDB.PutItem(table);
|
|
9
|
+
return {
|
|
10
|
+
fetch: Effect.gen(function* () {
|
|
11
|
+
yield* putItem({ Item: { id: { S: crypto.randomUUID() } } });
|
|
12
|
+
return HttpServerResponse.empty({ status: 202 });
|
|
13
|
+
}),
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```ts never
|
|
19
|
+
Effect.gen(function* () {
|
|
20
|
+
const putItem = yield* AWS.DynamoDB.PutItem(table);
|
|
21
|
+
yield* putItem({ Item: { id: { S: "seed" } } }); // runs at deploy and on every cold start
|
|
22
|
+
return { fetch: handler };
|
|
23
|
+
});
|
|
24
|
+
```
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: In a Worker, Function, or Service, Config values and binding factories must be yielded in the outer construction Effect and captured for the handlers, never yielded for the first time inside fetch or another request handler, which does not run at deploy time, so nothing gets bound.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
```ts must
|
|
6
|
+
export default Cloudflare.Worker("Api", { main: import.meta.url },
|
|
7
|
+
Effect.gen(function* () {
|
|
8
|
+
const apiKey = yield* Config.Redacted("API_KEY");
|
|
9
|
+
const bucket = yield* Cloudflare.R2.ReadBucket(Uploads);
|
|
10
|
+
return {
|
|
11
|
+
fetch: Effect.gen(function* () {
|
|
12
|
+
const object = yield* bucket.get("report.json");
|
|
13
|
+
return HttpServerResponse.text(Redacted.value(apiKey).slice(0, 4));
|
|
14
|
+
}),
|
|
15
|
+
};
|
|
16
|
+
}),
|
|
17
|
+
);
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```ts never
|
|
21
|
+
export default Cloudflare.Worker("Api", { main: import.meta.url },
|
|
22
|
+
Effect.gen(function* () {
|
|
23
|
+
return {
|
|
24
|
+
fetch: Effect.gen(function* () {
|
|
25
|
+
const apiKey = yield* Config.Redacted("API_KEY"); // never bound: API_KEY is missing at runtime
|
|
26
|
+
return HttpServerResponse.text("ok");
|
|
27
|
+
}),
|
|
28
|
+
};
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A token the infrastructure owns, such as a webhook secret or bearer token, should be minted once with alchemy's Random or left for alchemy to provision, and should not be a hardcoded literal or regenerated on every deploy with crypto.randomUUID().
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
```ts should
|
|
6
|
+
export const WebhookSecret = Random("WebhookSecret");
|
|
7
|
+
const auth = yield* BetterAuth({ basePath: "/api/auth", emailAndPassword: { enabled: true } });
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```ts should not
|
|
11
|
+
const webhookSecret = crypto.randomUUID(); // a new secret on every deploy
|
|
12
|
+
const auth = yield* BetterAuth({ basePath: "/api/auth", secret: Redacted.make("dev-secret") });
|
|
13
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A secret, credential, connection string, token, signed URL, or private key must never be logged, returned in an HTTP response, or returned as a stack output; code must expose only names, presence, or non-secret metadata.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
```ts must
|
|
6
|
+
if (url.pathname === "/secret") {
|
|
7
|
+
return yield* HttpServerResponse.json({ name: "API_TOKEN", set: Redacted.value(token).length > 0 });
|
|
8
|
+
}
|
|
9
|
+
return { serial: certificate.serial, expires: certificate.notAfter }; // stack output
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```ts never
|
|
13
|
+
yield* Effect.log(`token=${Redacted.value(token)}`);
|
|
14
|
+
return { databaseUrl: connection.databaseUrl, tlsKey: certificate.privateKey }; // stack output
|
|
15
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A value in an env key that a framework inlines into the browser bundle, such as VITE_, NEXT_PUBLIC_, PUBLIC_, or NUXT_PUBLIC_, must be public configuration, never a secret, credential, or connection string, even wrapped in Redacted.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
```ts must
|
|
6
|
+
const site = yield* Cloudflare.Website.Vite("Site", {
|
|
7
|
+
env: { VITE_API_URL: api.url, STRIPE_SECRET_KEY: Config.Redacted("STRIPE_SECRET_KEY") },
|
|
8
|
+
});
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ts never
|
|
12
|
+
const site = yield* Cloudflare.Website.Vite("Site", {
|
|
13
|
+
env: { VITE_STRIPE_SECRET_KEY: Config.Redacted("STRIPE_SECRET_KEY"), VITE_DATABASE_URL: branch.connectionUri },
|
|
14
|
+
});
|
|
15
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A secret should be read with Config.Redacted, passed to resources and env as Redacted, and unwrapped with Redacted.value only at the call that needs the raw string; it should not be read with Config.String or process.env, or unwrapped early into plain variables.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
```ts should
|
|
6
|
+
const apiKey = yield* Config.Redacted("OPENAI_API_KEY");
|
|
7
|
+
return {
|
|
8
|
+
fetch: Effect.gen(function* () {
|
|
9
|
+
const request = HttpClientRequest.post(url).pipe(HttpClientRequest.bearerToken(Redacted.value(apiKey)));
|
|
10
|
+
return yield* client.execute(request);
|
|
11
|
+
}),
|
|
12
|
+
};
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```ts should not
|
|
16
|
+
const apiKey = Redacted.value(yield* Config.Redacted("OPENAI_API_KEY"));
|
|
17
|
+
const webhook = process.env.SLACK_WEBHOOK_URL!;
|
|
18
|
+
return { fetch: handler(apiKey, webhook) };
|
|
19
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A Redacted value must be unwrapped with Redacted.value before it is compared, sent, or serialized, never put into a template literal, string concatenation, String(), or JSON, where it becomes the literal text <redacted> and silently fails every check.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
```ts must
|
|
6
|
+
const expected = yield* Config.Redacted("AUTH_TOKEN");
|
|
7
|
+
if (request.headers.authorization !== `Bearer ${Redacted.value(expected)}`) {
|
|
8
|
+
return HttpServerResponse.empty({ status: 401 });
|
|
9
|
+
}
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```ts never
|
|
13
|
+
const expected = yield* Config.Redacted("AUTH_TOKEN");
|
|
14
|
+
if (request.headers.authorization !== `Bearer ${expected}`) { // compares against "Bearer <redacted>"
|
|
15
|
+
return HttpServerResponse.empty({ status: 401 });
|
|
16
|
+
}
|
|
17
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A handler behind a public URL, such as a Function URL, workers.dev route, or a Durable Object chosen by a client-supplied name, must authenticate the caller and check its access before doing protected work, never treating CORS, an obscure URL, or a trusted origin as authorization.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
```ts must
|
|
6
|
+
fetch: Effect.gen(function* () {
|
|
7
|
+
const request = yield* HttpServerRequest.HttpServerRequest;
|
|
8
|
+
const user = yield* verifySession(request.headers.authorization);
|
|
9
|
+
if (user === undefined) return HttpServerResponse.empty({ status: 401 });
|
|
10
|
+
return yield* counters.fetch(user.tenantId, request);
|
|
11
|
+
}),
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```ts never
|
|
15
|
+
fetch: Effect.gen(function* () {
|
|
16
|
+
const request = yield* HttpServerRequest.HttpServerRequest;
|
|
17
|
+
const name = /^\/counters\/([^/]+)$/.exec(new URL(request.url).pathname)?.[1] ?? "default";
|
|
18
|
+
return yield* counters.fetch(name, request); // anyone who knows a name can read it
|
|
19
|
+
}),
|
|
20
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A sensitive CI value, such as a token, key, password, or connection string, must be stored with GitHub.Secret, never with GitHub.Variable, which is plain text and visible in logs.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
```ts must
|
|
6
|
+
yield* GitHub.Secret("CloudflareToken", { owner, repository, name: "CLOUDFLARE_API_TOKEN", value: token.value });
|
|
7
|
+
yield* GitHub.Variable("AwsRegion", { owner, repository, name: "AWS_REGION", value: "us-east-1" });
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```ts never
|
|
11
|
+
yield* GitHub.Variables({ owner, repository, variables: { CLOUDFLARE_API_TOKEN: token.value } });
|
|
12
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: The acting user must come from the session or token verified in that request, never from an ID in the request body, query, or headers, and never from a user cached in a process-wide layer.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
```ts must
|
|
6
|
+
const session = yield* auth.getSession();
|
|
7
|
+
if (session === null) return yield* Effect.fail(new Unauthorized());
|
|
8
|
+
const rows = yield* sql`SELECT * FROM uploads WHERE owner_id = ${session.user.id}`;
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ts never
|
|
12
|
+
const { ownerId } = yield* HttpServerRequest.schemaBodyJson(Schema.Struct({ ownerId: Schema.String }));
|
|
13
|
+
const rows = yield* sql`SELECT * FROM uploads WHERE owner_id = ${ownerId}`;
|
|
14
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Auth configuration must keep state, PKCE, origin and CSRF checks, ID-token verification, and email verification enabled, never disable them or merge accounts by email alone to work around an error or a cross-origin setup.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
```ts must
|
|
6
|
+
const auth = yield* BetterAuth({
|
|
7
|
+
trustedOrigins: [siteOrigin],
|
|
8
|
+
emailAndPassword: { enabled: true, requireEmailVerification: true },
|
|
9
|
+
account: { accountLinking: { disableImplicitLinking: true } },
|
|
10
|
+
});
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```ts never
|
|
14
|
+
const auth = yield* BetterAuth({
|
|
15
|
+
trustedOrigins: ["*"],
|
|
16
|
+
advanced: { disableCSRFCheck: true },
|
|
17
|
+
account: { accountLinking: { trustedProviders: ["github", "google", "company"] } },
|
|
18
|
+
});
|
|
19
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A runtime should bind the narrowest capability its calls need, a Read or Write binding when it only reads or only writes, and should not bind ReadWrite for one-sided use.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
```ts should
|
|
6
|
+
const bucket = yield* Cloudflare.R2.ReadBucket(Uploads);
|
|
7
|
+
return { fetch: Effect.gen(function* () { return yield* bucket.get("report.json"); }) };
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```ts should not
|
|
11
|
+
const bucket = yield* Cloudflare.R2.ReadWriteBucket(Uploads); // only get is ever called
|
|
12
|
+
return { fetch: Effect.gen(function* () { return yield* bucket.get("report.json"); }) };
|
|
13
|
+
```
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Credentials a stack mints for CI should be scoped to what the app deploys, using a GitHub OIDC role pinned to the repository on AWS and a trimmed permission list on Cloudflare, and should not grant AdministratorAccess or unused write scopes.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
```ts should
|
|
6
|
+
const role = yield* AWS.IAM.Role("GitHubDeploy", {
|
|
7
|
+
assumeRolePolicyDocument: { Version: "2012-10-17", Statement: [{
|
|
8
|
+
Effect: "Allow", Principal: { Federated: oidc.openIDConnectProviderArn },
|
|
9
|
+
Action: ["sts:AssumeRoleWithWebIdentity"],
|
|
10
|
+
Condition: { StringLike: { "token.actions.githubusercontent.com:sub": "repo:acme/app:*" } },
|
|
11
|
+
}] },
|
|
12
|
+
policies: [deployPolicy],
|
|
13
|
+
});
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```ts should not
|
|
17
|
+
const role = yield* AWS.IAM.Role("GitHubDeploy", {
|
|
18
|
+
assumeRolePolicyDocument: { Version: "2012-10-17", Statement: [{
|
|
19
|
+
Effect: "Allow", Principal: { Federated: oidc.openIDConnectProviderArn },
|
|
20
|
+
Action: ["sts:AssumeRoleWithWebIdentity"],
|
|
21
|
+
}] },
|
|
22
|
+
managedPolicyArns: ["arn:aws:iam::aws:policy/AdministratorAccess"],
|
|
23
|
+
});
|
|
24
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Only a null session may be treated as anonymous; a failed session lookup must stay in the error channel or map to a server error, never be collapsed into signed out.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
```ts must
|
|
6
|
+
const session = yield* auth.getSession().pipe(
|
|
7
|
+
Effect.mapError(() => new AuthenticationUnavailable()),
|
|
8
|
+
);
|
|
9
|
+
if (session === null) return yield* Effect.fail(new Unauthorized());
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```ts never
|
|
13
|
+
const session = yield* auth.getSession().pipe(Effect.orElseSucceed(() => null));
|
|
14
|
+
if (session === null) return yield* Effect.fail(new Unauthorized());
|
|
15
|
+
```
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: A call over the network, such as an HTTP request, a database query, or a third-party API,
|
|
2
|
+
description: A call over the network, such as an HTTP request, a database query, or a third-party API, should carry a timeout and a retry schedule, and should not go out bare. A call through a client that already applies both, such as an HttpClient built with a timeout and retryTransient or an SDK with its own retries, has them. Calls through the platform FileSystem and Path services are local and are not in scope.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
```ts
|
|
5
|
+
```ts should
|
|
6
6
|
const retryPolicy = Schedule.exponential("100 millis").pipe(Schedule.both(Schedule.recurs(3)));
|
|
7
7
|
|
|
8
8
|
const resilientCall = HttpClient.get("https://api.example.com/users").pipe(
|
|
@@ -12,6 +12,6 @@ const resilientCall = HttpClient.get("https://api.example.com/users").pipe(
|
|
|
12
12
|
);
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
```ts
|
|
15
|
+
```ts should not
|
|
16
16
|
const response = yield* HttpClient.get(`https://api.example.com/users/${id}`);
|
|
17
17
|
```
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: A test
|
|
2
|
+
description: A test should supply config with Layer.succeed on the config service, and should not set environment variables or a ConfigProvider to do it.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
```ts
|
|
5
|
+
```ts should
|
|
6
6
|
Effect.runPromise(
|
|
7
7
|
program.pipe(
|
|
8
8
|
Effect.provide(
|
|
@@ -15,7 +15,7 @@ Effect.runPromise(
|
|
|
15
15
|
);
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
```ts
|
|
18
|
+
```ts should not
|
|
19
19
|
beforeAll(() => {
|
|
20
20
|
process.env.DATABASE_URL = "postgres://localhost/test";
|
|
21
21
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: A config value with constraints must be read with Config.schema, never
|
|
2
|
+
description: A config value with constraints must be validated as it is read, with Config.schema or Config.mapOrFail, never checked by hand after reading.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
```ts must
|
|
@@ -12,8 +12,19 @@ const Environment = Schema.Literals(["development", "staging", "production"]);
|
|
|
12
12
|
const program = Effect.gen(function* () {
|
|
13
13
|
const port = yield* Config.schema(Port, "PORT");
|
|
14
14
|
const env = yield* Config.schema(Environment, "ENV");
|
|
15
|
+
const timeoutMs = yield* Config.int("TIMEOUT_MS").pipe(
|
|
16
|
+
Config.mapOrFail((ms) =>
|
|
17
|
+
ms > 0 && ms <= 60_000
|
|
18
|
+
? Effect.succeed(ms)
|
|
19
|
+
: Effect.fail(
|
|
20
|
+
new Config.ConfigError(
|
|
21
|
+
new ConfigProvider.SourceError({ message: "TIMEOUT_MS must be 1 to 60000" }),
|
|
22
|
+
),
|
|
23
|
+
),
|
|
24
|
+
),
|
|
25
|
+
);
|
|
15
26
|
|
|
16
|
-
return { port, env };
|
|
27
|
+
return { port, env, timeoutMs };
|
|
17
28
|
});
|
|
18
29
|
```
|
|
19
30
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Structured variants must be Schema.TaggedClass
|
|
2
|
+
description: Structured variants, alternatives that carry fields, must be Schema.TaggedClass members of a Schema.Union, never hand-written object types joined by a tag field. Simple alternatives without fields may be Schema.Literals.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
```ts must
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A service that depends on other services must be a Context.Service built by a Layer that yields those services as it is constructed, never a factory function or class that takes services, clients, or their implementations as arguments.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
```ts must
|
|
6
|
+
class Users extends Context.Service<
|
|
7
|
+
Users,
|
|
8
|
+
{
|
|
9
|
+
readonly find: (id: UserId) => Effect.Effect<User, UserNotFound>;
|
|
10
|
+
}
|
|
11
|
+
>()("@app/Users") {}
|
|
12
|
+
|
|
13
|
+
export const UsersLive = Layer.effect(
|
|
14
|
+
Users,
|
|
15
|
+
Effect.gen(function* () {
|
|
16
|
+
const database = yield* Database;
|
|
17
|
+
const logger = yield* Logger;
|
|
18
|
+
return Users.of({
|
|
19
|
+
find: (id) =>
|
|
20
|
+
Effect.gen(function* () {
|
|
21
|
+
yield* logger.log(`finding ${id}`);
|
|
22
|
+
return yield* database.findUser(id);
|
|
23
|
+
}),
|
|
24
|
+
});
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```ts never
|
|
30
|
+
export const makeUsers = (database: Database["Service"], logger: Logger["Service"]) => ({
|
|
31
|
+
find: (id: UserId) =>
|
|
32
|
+
Effect.gen(function* () {
|
|
33
|
+
yield* logger.log(`finding ${id}`);
|
|
34
|
+
return yield* database.findUser(id);
|
|
35
|
+
}),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const program = Effect.gen(function* () {
|
|
39
|
+
const users = makeUsers(yield* Database, yield* Logger);
|
|
40
|
+
return yield* users.find(userId);
|
|
41
|
+
});
|
|
42
|
+
```
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A service's operations must have no requirements, typed Effect<A, E, never>, with their dependencies acquired when the service's Layer is built, never left in an operation's requirements type, as in Effect<A, E, Database>.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
```ts must
|
|
6
|
+
class Users extends Context.Service<
|
|
7
|
+
Users,
|
|
8
|
+
{
|
|
9
|
+
readonly find: (id: UserId) => Effect.Effect<User, UserNotFound, never>;
|
|
10
|
+
}
|
|
11
|
+
>()("@app/Users") {}
|
|
12
|
+
|
|
13
|
+
export const UsersLive = Layer.effect(
|
|
14
|
+
Users,
|
|
15
|
+
Effect.gen(function* () {
|
|
16
|
+
const database = yield* Database;
|
|
17
|
+
const logger = yield* Logger;
|
|
18
|
+
return Users.of({
|
|
19
|
+
find: (id) =>
|
|
20
|
+
Effect.gen(function* () {
|
|
21
|
+
yield* logger.log(`finding ${id}`);
|
|
22
|
+
return yield* database.findUser(id);
|
|
23
|
+
}),
|
|
24
|
+
});
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```ts never
|
|
30
|
+
class Users extends Context.Service<
|
|
31
|
+
Users,
|
|
32
|
+
{
|
|
33
|
+
readonly find: (id: UserId) => Effect.Effect<User, UserNotFound, Database | Logger>;
|
|
34
|
+
}
|
|
35
|
+
>()("@app/Users") {}
|
|
36
|
+
|
|
37
|
+
export const UsersLive = Layer.succeed(Users, {
|
|
38
|
+
find: (id) =>
|
|
39
|
+
Effect.gen(function* () {
|
|
40
|
+
const database = yield* Database;
|
|
41
|
+
const logger = yield* Logger;
|
|
42
|
+
yield* logger.log(`finding ${id}`);
|
|
43
|
+
return yield* database.findUser(id);
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
```
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: A test implementation of a service
|
|
2
|
+
description: A test implementation of a service should be built with Layer.sync or Layer.succeed over in-memory state, and should not reach a real database, network, or file. Integration tests that exercise the real service are not in scope.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
```ts
|
|
5
|
+
```ts should
|
|
6
6
|
static readonly testLayer = Layer.sync(Cache, () => {
|
|
7
7
|
const store = new Map<string, string>()
|
|
8
8
|
|
|
@@ -13,7 +13,7 @@ static readonly testLayer = Layer.sync(Cache, () => {
|
|
|
13
13
|
})
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
```ts
|
|
16
|
+
```ts should not
|
|
17
17
|
const EmailTest = Layer.effect(
|
|
18
18
|
Email,
|
|
19
19
|
Effect.gen(function* () {
|