domainkit 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +213 -41
- package/dist/Approval.mjs +34 -0
- package/dist/Approval.mjs.map +1 -0
- package/dist/Cleanup.mjs +97 -0
- package/dist/Cleanup.mjs.map +1 -0
- package/dist/Cloudflare.mjs +225 -0
- package/dist/Cloudflare.mjs.map +1 -0
- package/dist/Connect.mjs +463 -0
- package/dist/Connect.mjs.map +1 -0
- package/dist/Custody.mjs +64 -0
- package/dist/Custody.mjs.map +1 -0
- package/dist/DnsRecord.mjs +213 -0
- package/dist/DnsRecord.mjs.map +1 -0
- package/dist/DomainKit.mjs +34 -0
- package/dist/DomainKit.mjs.map +1 -0
- package/dist/DomainName.mjs +100 -0
- package/dist/DomainName.mjs.map +1 -0
- package/dist/Plan.mjs +102 -0
- package/dist/Plan.mjs.map +1 -0
- package/dist/Principal.mjs +23 -0
- package/dist/Principal.mjs.map +1 -0
- package/dist/Provider.mjs +130 -0
- package/dist/Provider.mjs.map +1 -0
- package/dist/Providers.mjs +40 -0
- package/dist/Providers.mjs.map +1 -0
- package/dist/Provision.mjs +93 -0
- package/dist/Provision.mjs.map +1 -0
- package/dist/Reason.mjs +162 -0
- package/dist/Reason.mjs.map +1 -0
- package/dist/Receipt.mjs +71 -0
- package/dist/Receipt.mjs.map +1 -0
- package/dist/Resolver.mjs +75 -0
- package/dist/Resolver.mjs.map +1 -0
- package/dist/Server.mjs +516 -0
- package/dist/Server.mjs.map +1 -0
- package/dist/Storage.mjs +236 -0
- package/dist/Storage.mjs.map +1 -0
- package/dist/Testing.mjs +226 -0
- package/dist/Testing.mjs.map +1 -0
- package/dist/Transport.mjs +187 -0
- package/dist/Transport.mjs.map +1 -0
- package/dist/Vercel.mjs +116 -0
- package/dist/Vercel.mjs.map +1 -0
- package/dist/Verify.mjs +292 -0
- package/dist/Verify.mjs.map +1 -0
- package/dist/{rolldown-runtime-D7D4PA-g.mjs → _virtual/_rolldown/runtime.mjs} +1 -1
- package/dist/entry/client.mjs +2 -0
- package/dist/entry/server.mjs +2 -0
- package/dist/entry/testing.mjs +3 -0
- package/dist/index.mjs +33 -462
- package/dist/index.mjs.map +1 -1
- package/dist/internal/aes.mjs +61 -0
- package/dist/internal/aes.mjs.map +1 -0
- package/dist/internal/attempts.mjs +284 -0
- package/dist/internal/attempts.mjs.map +1 -0
- package/dist/internal/cloudflare/client.mjs +100 -0
- package/dist/internal/cloudflare/client.mjs.map +1 -0
- package/dist/internal/cloudflare/protocol.mjs +59 -0
- package/dist/internal/cloudflare/protocol.mjs.map +1 -0
- package/dist/internal/cloudflare/records.mjs +156 -0
- package/dist/internal/cloudflare/records.mjs.map +1 -0
- package/dist/internal/config.mjs +13 -0
- package/dist/internal/config.mjs.map +1 -0
- package/dist/internal/conformance/provider.mjs +207 -0
- package/dist/internal/conformance/provider.mjs.map +1 -0
- package/dist/internal/conformance/storage.mjs +428 -0
- package/dist/internal/conformance/storage.mjs.map +1 -0
- package/dist/internal/digest.mjs +25 -0
- package/dist/internal/digest.mjs.map +1 -0
- package/dist/internal/doh.mjs +153 -0
- package/dist/internal/doh.mjs.map +1 -0
- package/dist/internal/error.mjs +69 -0
- package/dist/internal/error.mjs.map +1 -0
- package/dist/internal/http.mjs +90 -0
- package/dist/internal/http.mjs.map +1 -0
- package/dist/internal/ids.mjs +8 -0
- package/dist/internal/ids.mjs.map +1 -0
- package/dist/internal/oauth.mjs +113 -0
- package/dist/internal/oauth.mjs.map +1 -0
- package/dist/internal/planner.mjs +88 -0
- package/dist/internal/planner.mjs.map +1 -0
- package/dist/internal/provider-async.mjs +83 -0
- package/dist/internal/provider-async.mjs.map +1 -0
- package/dist/internal/storage-memory.mjs +302 -0
- package/dist/internal/storage-memory.mjs.map +1 -0
- package/dist/internal/vercel/client.mjs +122 -0
- package/dist/internal/vercel/client.mjs.map +1 -0
- package/dist/internal/vercel/protocol.mjs +80 -0
- package/dist/internal/vercel/protocol.mjs.map +1 -0
- package/dist/internal/vercel/records.mjs +151 -0
- package/dist/internal/vercel/records.mjs.map +1 -0
- package/dist/package.mjs +6 -0
- package/dist/package.mjs.map +1 -0
- package/dist/types/Approval.d.ts +26 -0
- package/dist/types/Cleanup.d.ts +41 -0
- package/dist/types/Cloudflare.d.ts +36 -0
- package/dist/types/Connect.d.ts +506 -0
- package/dist/types/Custody.d.ts +38 -0
- package/dist/types/DnsRecord.d.ts +158 -0
- package/dist/types/DomainKit.d.ts +31 -0
- package/dist/types/DomainName.d.ts +27 -0
- package/dist/types/Plan.d.ts +76 -0
- package/dist/types/Principal.d.ts +18 -0
- package/dist/types/Provider.d.ts +248 -0
- package/dist/types/Providers.d.ts +16 -0
- package/dist/types/Provision.d.ts +75 -0
- package/dist/types/Reason.d.ts +118 -0
- package/dist/types/Receipt.d.ts +55 -0
- package/dist/types/Resolver.d.ts +135 -0
- package/dist/types/Server.d.ts +6523 -0
- package/dist/types/Storage.d.ts +314 -0
- package/dist/types/Testing.d.ts +84 -0
- package/dist/types/Transport.d.ts +124 -0
- package/dist/types/Vercel.d.ts +25 -0
- package/dist/types/Verify.d.ts +109 -0
- package/dist/types/entry/client.d.ts +2 -0
- package/dist/types/entry/server.d.ts +2 -0
- package/dist/types/entry/testing.d.ts +3 -0
- package/dist/types/index.d.ts +21 -0
- package/dist/types/internal/aes.d.ts +8 -0
- package/dist/types/internal/attempts.d.ts +40 -0
- package/dist/types/internal/cloudflare/client.d.ts +23 -0
- package/dist/types/internal/cloudflare/protocol.d.ts +71 -0
- package/dist/types/internal/cloudflare/records.d.ts +8 -0
- package/dist/types/internal/config.d.ts +4 -0
- package/dist/types/internal/conformance/provider.d.ts +12 -0
- package/dist/types/internal/conformance/storage.d.ts +14 -0
- package/dist/types/internal/digest.d.ts +10 -0
- package/dist/types/internal/doh.d.ts +19 -0
- package/dist/types/internal/error.d.ts +24 -0
- package/dist/types/internal/http.d.ts +27 -0
- package/dist/types/internal/ids.d.ts +3 -0
- package/dist/types/internal/oauth.d.ts +58 -0
- package/dist/types/internal/planner.d.ts +22 -0
- package/dist/types/internal/provider-async.d.ts +78 -0
- package/dist/types/internal/storage-memory.d.ts +2 -0
- package/dist/types/internal/vercel/client.d.ts +29 -0
- package/dist/types/internal/vercel/protocol.d.ts +124 -0
- package/dist/types/internal/vercel/records.d.ts +7 -0
- package/package.json +88 -12
- package/dist/canonical-json-CQYKsZw7.mjs +0 -310
- package/dist/canonical-json-CQYKsZw7.mjs.map +0 -1
- package/dist/connect-D-X8T9W8.d.mts +0 -1597
- package/dist/connect-oI8hcMxc.mjs +0 -185
- package/dist/connect-oI8hcMxc.mjs.map +0 -1
- package/dist/connection-Dp6ZdSo8.d.mts +0 -186
- package/dist/connection-api-lqG1nBtD.d.mts +0 -26
- package/dist/index.d.mts +0 -1551
- package/dist/managed-dns-connections-C3A_GBaz.mjs +0 -99
- package/dist/managed-dns-connections-C3A_GBaz.mjs.map +0 -1
- package/dist/promise.d.mts +0 -378
- package/dist/promise.mjs +0 -598
- package/dist/promise.mjs.map +0 -1
- package/dist/resolver-BeGz1ukw.mjs +0 -921
- package/dist/resolver-BeGz1ukw.mjs.map +0 -1
- package/dist/resolver-xoSwyXDJ.d.mts +0 -3734
- package/dist/server.d.mts +0 -128
- package/dist/server.mjs +0 -319
- package/dist/server.mjs.map +0 -1
- package/dist/testing.d.mts +0 -62
- package/dist/testing.mjs +0 -507
- package/dist/testing.mjs.map +0 -1
- package/dist/verify-DvGPb5C9.mjs +0 -2402
- package/dist/verify-DvGPb5C9.mjs.map +0 -1
- package/dist/verify-FpunAcM7.d.mts +0 -1335
package/README.md
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Add custom domains to a TypeScript SaaS application.
|
|
4
4
|
|
|
5
|
-
DomainKit turns DNS requirements into plans
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
DomainKit turns DNS requirements into plans a customer can review, applies only the plan digest
|
|
6
|
+
they approved, keeps a receipt of every write, and plans cleanup from that receipt. Cloudflare and
|
|
7
|
+
Vercel are built in; a provider is one declarative value, so tokens-only providers are the same
|
|
8
|
+
shape minus the OAuth case.
|
|
8
9
|
|
|
9
10
|
## Install
|
|
10
11
|
|
|
@@ -14,62 +15,233 @@ npm install domainkit effect@rc
|
|
|
14
15
|
|
|
15
16
|
Node.js 24.10 or newer and Effect 4 are required.
|
|
16
17
|
|
|
17
|
-
## Plan, approve,
|
|
18
|
+
## Plan, approve, apply
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { Effect, Match } from "effect";
|
|
22
|
+
import { DnsRecord, DomainKit, Principal, Provision, Verify } from "domainkit";
|
|
23
|
+
import { Testing } from "domainkit/testing";
|
|
24
|
+
|
|
25
|
+
const requirements = [
|
|
26
|
+
DnsRecord.cname({ name: "app.example.com", target: "edge.acme.dev", purpose: "Serve your site" }),
|
|
27
|
+
DnsRecord.txt({
|
|
28
|
+
name: "_acme.app.example.com",
|
|
29
|
+
value: "acme-verify=7f3a",
|
|
30
|
+
purpose: "Prove ownership",
|
|
31
|
+
}),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const program = Effect.gen(function* () {
|
|
35
|
+
const plan = yield* Provision.plan({ domain: "app.example.com", requirements });
|
|
36
|
+
// ^ operations: [Create CNAME, Noop TXT] with a digest the customer approves
|
|
37
|
+
|
|
38
|
+
const approval = yield* Provision.approve(plan);
|
|
39
|
+
const receipt = yield* Provision.apply(approval);
|
|
40
|
+
// ^ status: "complete" | "partial", one outcome per operation, safe to retry
|
|
41
|
+
|
|
42
|
+
const readiness = yield* Verify.observe({ domain: "app.example.com" });
|
|
43
|
+
return { receipt, ready: readiness.overall === "ready", nextCheckAt: readiness.nextCheckAt };
|
|
44
|
+
}).pipe(
|
|
45
|
+
Effect.catchTag("DomainKitError", (error) =>
|
|
46
|
+
Match.value(error.reason).pipe(
|
|
47
|
+
Match.tag("Conflict", ({ operations }) =>
|
|
48
|
+
Effect.fail(`Fix ${operations.length} conflicting record(s) first`),
|
|
49
|
+
),
|
|
50
|
+
Match.tag("Stale", () => Effect.fail("Provider changed under us; plan again")),
|
|
51
|
+
Match.orElse(() => Effect.fail(error.message)),
|
|
52
|
+
),
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
export const main = program.pipe(
|
|
57
|
+
Effect.provideService(Principal.Service, { ownerId: "org_42", actorId: "user_7" }),
|
|
58
|
+
Effect.provide(
|
|
59
|
+
DomainKit.layerMemory({ providers: [Testing.provider({ zones: ["example.com"] })] }),
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Plans are additive and fail closed: exact records are `Noop`, missing records are `Create`, and
|
|
65
|
+
incompatible state is `Conflict`. DomainKit never updates or deletes a record it did not create,
|
|
66
|
+
and cleanup is its own plan, approval, and receipt built from the apply receipt.
|
|
67
|
+
|
|
68
|
+
Every step is a stored attempt, so a host can render the plan in one request, collect consent in
|
|
69
|
+
another, and apply in a third; retrying any step replays its result. A customer who declines calls
|
|
70
|
+
`Provision.reject`, which closes the attempt for good and leaves the domain free for a new plan. Every failure is one
|
|
71
|
+
`DomainKit.Error` whose `reason` you match on; `category`, `isRetryable`, and `httpStatus` derive
|
|
72
|
+
from it.
|
|
73
|
+
|
|
74
|
+
## Wire it into your app
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { Config, Layer } from "effect";
|
|
78
|
+
import { Cloudflare, Custody, DomainKit, Vercel } from "domainkit";
|
|
79
|
+
|
|
80
|
+
export const DomainKitLive = DomainKit.layer({
|
|
81
|
+
providers: [
|
|
82
|
+
Cloudflare.provider({
|
|
83
|
+
oauth: {
|
|
84
|
+
clientId: Config.string("CF_CLIENT_ID"),
|
|
85
|
+
clientSecret: Config.redacted("CF_CLIENT_SECRET"),
|
|
86
|
+
},
|
|
87
|
+
}),
|
|
88
|
+
Vercel.provider(), // tokens only
|
|
89
|
+
],
|
|
90
|
+
}).pipe(
|
|
91
|
+
// `provideMerge`, not `provide`: `domainkit/server`'s handlers read attempts and receipts
|
|
92
|
+
// straight from Storage, so the layer they are given has to still carry it.
|
|
93
|
+
Layer.provideMerge(Layer.mergeAll(YourStorage, Custody.layerConfig())),
|
|
94
|
+
);
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The host provides two services beneath the layer and one per request:
|
|
98
|
+
|
|
99
|
+
- `Storage` — every durable row (authorizations with sealed credentials, connections, attachments,
|
|
100
|
+
continuations, attempts, readiness). `Storage.layerMemory` is for tests; the Postgres
|
|
101
|
+
implementation lives in `@domainkit/capsuledb`; `Storage.layerFromAsync` wraps a Promise-shaped
|
|
102
|
+
implementation of your own. `Testing.conformance.storage` checks any implementation.
|
|
103
|
+
- `Custody` — seals credentials before Storage sees them. `Custody.layerConfig()` reads a 32-byte
|
|
104
|
+
key from `DOMAINKIT_CUSTODY_KEY`; `Custody.layerFromAsync` wraps a KMS.
|
|
105
|
+
- `Principal` — `{ ownerId, actorId }` per request. Every Storage read and write is scoped by it, so
|
|
106
|
+
cross-tenant access is a type error, not a runtime check.
|
|
107
|
+
|
|
108
|
+
`Connect.start` connects a provider (a token in one call, OAuth or a marketplace integration via a
|
|
109
|
+
redirect and `Connect.complete`), attaches domains, refreshes credentials before they expire, and
|
|
110
|
+
revokes them on disconnect. `Verify.observe` reads the provider and public DNS, stores readiness
|
|
111
|
+
per requirement, and tells you when to look again; pass `requirements` to observe records a
|
|
112
|
+
customer applies by hand on a domain with no attachment.
|
|
113
|
+
|
|
114
|
+
## Mount the routes
|
|
18
115
|
|
|
19
116
|
```ts
|
|
20
|
-
import { Digest, DnsRecord, DomainName, Provisioning } from "domainkit";
|
|
21
|
-
import { InMemoryDnsProvider } from "domainkit/testing";
|
|
22
117
|
import { Effect, Layer } from "effect";
|
|
118
|
+
import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi";
|
|
119
|
+
import { DomainKit, Reason } from "domainkit";
|
|
120
|
+
import { Server } from "domainkit/server";
|
|
121
|
+
|
|
122
|
+
// Verify a credential you issued and look the tenant up yourself. A request never names its own
|
|
123
|
+
// `ownerId`, and one you cannot attribute fails closed. Read it from a cookie: `/callback/:provider`
|
|
124
|
+
// is a browser navigation, so only what the browser sends by itself arrives with it.
|
|
125
|
+
const IdentityLive = Layer.succeed(Server.Identity)({
|
|
126
|
+
principal: (request) =>
|
|
127
|
+
Effect.flatMap(yourSessions.verify(request.cookies.session), (session) =>
|
|
128
|
+
session === null
|
|
129
|
+
? Effect.fail(
|
|
130
|
+
new DomainKit.Error({ reason: new Reason.Unauthenticated({ message: "No session" }) }),
|
|
131
|
+
)
|
|
132
|
+
: Effect.succeed({ ownerId: session.orgId, actorId: session.userId }),
|
|
133
|
+
),
|
|
134
|
+
// Optional: which routes this principal may reach. Members read, administrators write.
|
|
135
|
+
authorize: (principal, endpoint) =>
|
|
136
|
+
principal.actorId === "admin" || !writeRoutes.has(endpoint)
|
|
137
|
+
? Effect.void
|
|
138
|
+
: Effect.fail(
|
|
139
|
+
new DomainKit.Error({
|
|
140
|
+
reason: new Reason.Forbidden({ message: `${endpoint} needs an administrator` }),
|
|
141
|
+
}),
|
|
142
|
+
),
|
|
143
|
+
});
|
|
23
144
|
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
145
|
+
export const Api = HttpApi.make("app").add(Server.group);
|
|
146
|
+
|
|
147
|
+
export const ApiLive = HttpApiBuilder.layer(Api).pipe(
|
|
148
|
+
Layer.provide(Server.layer(Api, { defaultReturnTo: "/settings/domains" })),
|
|
149
|
+
Layer.provide([DomainKitLive, IdentityLive]),
|
|
150
|
+
);
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
`Server.group` is one `HttpApiGroup` with fifteen typed endpoints covering the whole lifecycle:
|
|
154
|
+
inspect, discover, connect, callback, attach, detach, disconnect, plan, approve, reject, apply, read
|
|
155
|
+
a plan or a receipt, observe, and build a cleanup plan. `Identity` is the only service you write, and
|
|
156
|
+
every handler derives the `Principal` for the request it is serving. `Server.group.prefix("/internal/dns")` moves
|
|
157
|
+
every route, and the OAuth callback URL follows the mount. `OpenApi.fromApi(Server.api)` documents
|
|
158
|
+
the group.
|
|
159
|
+
|
|
160
|
+
`/callback/:provider` is the one route the provider drives the browser to, so `Identity` has to
|
|
161
|
+
recognise a credential the browser sends on a top-level navigation.
|
|
162
|
+
|
|
163
|
+
`authorize` is optional and runs after `principal` on every request with the route's name, one of
|
|
164
|
+
`Server.EndpointName`. Fail it with reason `Forbidden` for the 403 a UI expects. Omit it and every
|
|
165
|
+
authenticated principal reaches every route, which is right when your own middleware already gates
|
|
166
|
+
the mount.
|
|
167
|
+
|
|
168
|
+
After an interactive connection completes, the callback redirects to the `returnTo` the flow was
|
|
169
|
+
started with, or to `defaultReturnTo`. The destination is resolved against the callback's own base
|
|
170
|
+
and must land on its origin, so neither the provider nor a crafted `returnTo` can steer the customer
|
|
171
|
+
off the application. Behind a proxy that rewrites `Host`, set `callbackBaseUrl` to the public base:
|
|
172
|
+
the request origin is one the browser never sees, and both the provider's callback URL and the
|
|
173
|
+
redirect follow the configured one.
|
|
174
|
+
|
|
175
|
+
Failures cross the wire as the `DomainKit.Error` value with the status its `reason` derives, so a
|
|
176
|
+
`Conflict` is a 409 carrying the conflicting operations and a `Reconnect` is a 403 naming the
|
|
177
|
+
connection.
|
|
178
|
+
|
|
179
|
+
Hosts that are not on Effect's HTTP stack use the Promise edge:
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
const { handler, dispose } = Server.toWebHandler(Layer.mergeAll(DomainKitLive, IdentityLive), {
|
|
183
|
+
prefix: "/api/domainkit",
|
|
34
184
|
});
|
|
185
|
+
```
|
|
35
186
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
187
|
+
## Talk to them from the browser
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
import { Transport } from "domainkit/client";
|
|
191
|
+
|
|
192
|
+
const transport = Transport.fromFetch("/api/domainkit");
|
|
193
|
+
|
|
194
|
+
// Effect at the call site, or `Transport.toAsync(transport)` for Promises.
|
|
195
|
+
const started =
|
|
196
|
+
yield *
|
|
197
|
+
transport.connection!.start({
|
|
198
|
+
domain: "app.example.com",
|
|
199
|
+
provider: "cloudflare",
|
|
200
|
+
method: Transport.Method.oauth({ returnTo: "/settings/domains" }),
|
|
42
201
|
});
|
|
43
|
-
const authorization = yield* Provisioning.authorize(plan);
|
|
44
|
-
return yield* Provisioning.apply({ authorization, plan });
|
|
45
|
-
}).pipe(Effect.provide(Layer.merge(InMemoryDnsProvider.layer(), Digest.webCryptoLayer)));
|
|
46
202
|
```
|
|
47
203
|
|
|
48
|
-
|
|
49
|
-
|
|
204
|
+
`connection.discover(domain)` answers which of the customer's existing connections already reaches
|
|
205
|
+
the domain, so a second domain on a connected provider skips the connect step entirely.
|
|
206
|
+
`Snapshot.providers[].methods[]` carries each method's label, docs URL, and token fields, so a
|
|
207
|
+
connect form renders from the response instead of hard-coding provider names.
|
|
208
|
+
|
|
209
|
+
Capability groups are optional. A host that mounts only the connection routes declares
|
|
210
|
+
`Transport.fromFetch(url, { capabilities: ["connection"] })`, `Transport.capabilities(transport)`
|
|
211
|
+
reports what is there, and the parts of `@domainkit/react` that plan or clean up do not render.
|
|
212
|
+
`Transport.fromAsync` adapts a Promise-shaped transport of your own.
|
|
213
|
+
|
|
214
|
+
Failures arrive as the same `DomainKit.Error` the lifecycle raised, reason intact, so a `Conflict`
|
|
215
|
+
still carries its conflicting operations. A response the transport cannot read as a `DomainKit.Error`
|
|
216
|
+
becomes a retryable `ProviderUnavailable` naming the base URL.
|
|
217
|
+
|
|
218
|
+
## Test against the seam
|
|
50
219
|
|
|
51
|
-
|
|
52
|
-
`
|
|
53
|
-
|
|
220
|
+
`domainkit/testing` ships `Testing.provider` (a token and OAuth provider over in-memory zones),
|
|
221
|
+
`Testing.resolver`, `Testing.storage`, `Testing.transport` (the whole lifecycle over an in-memory
|
|
222
|
+
server, recording every call), and the conformance runners, so host tests never stub global
|
|
223
|
+
`fetch`. Provider authors run `Testing.conformance.provider(definition, credential, zone)` against
|
|
224
|
+
a real account before shipping.
|
|
54
225
|
|
|
55
226
|
## Public entry points
|
|
56
227
|
|
|
57
|
-
- `domainkit` — Effect services,
|
|
58
|
-
- `domainkit/
|
|
59
|
-
- `domainkit/
|
|
60
|
-
- `domainkit/testing` —
|
|
228
|
+
- `domainkit` — the Effect-native root: lifecycle services, host seams, providers, and values;
|
|
229
|
+
- `domainkit/server` — the mountable route group, its layers, and the wire schemas;
|
|
230
|
+
- `domainkit/client` — the capability-gated fetch transport and its Promise adapters;
|
|
231
|
+
- `domainkit/testing` — fakes and conformance runners;
|
|
232
|
+
- `domainkit/<Module>` — every root namespace as its own subpath (`domainkit/Principal`,
|
|
233
|
+
`domainkit/DnsRecord`, ...), the same module instances the root re-exports, for declaration
|
|
234
|
+
emit and bundlers that want the declaring module.
|
|
61
235
|
|
|
62
|
-
Your app owns
|
|
63
|
-
|
|
64
|
-
Cloudflare's provider namespace includes credential refresh; the host owns refresh locking,
|
|
65
|
-
encrypted rotation, retry policy, and reconnect UX.
|
|
236
|
+
Your app owns identity, tenancy, persistence, keys, routes, and consent. DomainKit supplies the
|
|
237
|
+
lifecycle, not a hosted control plane.
|
|
66
238
|
|
|
67
239
|
## Learn more
|
|
68
240
|
|
|
69
241
|
- [Executable quickstart](https://github.com/AryaLabsHQ/domainkit/blob/main/packages/domainkit/examples/effect/quickstart.ts)
|
|
70
|
-
- [
|
|
71
|
-
- [
|
|
72
|
-
- [
|
|
242
|
+
- [Mounting the routes](https://github.com/AryaLabsHQ/domainkit/blob/main/packages/domainkit/examples/effect/server.ts)
|
|
243
|
+
- [Writing a provider](https://github.com/AryaLabsHQ/domainkit/blob/main/packages/domainkit/examples/effect/provider.ts)
|
|
244
|
+
- [Architecture decisions](https://github.com/AryaLabsHQ/domainkit/tree/main/packages/domainkit/docs/adr)
|
|
73
245
|
- [Issues](https://github.com/AryaLabsHQ/domainkit/issues)
|
|
74
246
|
|
|
75
247
|
## License
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { __exportAll } from "./_virtual/_rolldown/runtime.mjs";
|
|
2
|
+
import { Digest, Kind, OperationId, PlanId } from "./Plan.mjs";
|
|
3
|
+
import { decode as decode$1 } from "./internal/error.mjs";
|
|
4
|
+
import { Schema } from "effect";
|
|
5
|
+
//#region src/Approval.ts
|
|
6
|
+
/**
|
|
7
|
+
* A principal's consent to apply a specific plan digest. `Provision.apply` and `Cleanup.apply`
|
|
8
|
+
* accept only an Approval. The word "authorization" is reserved for provider OAuth.
|
|
9
|
+
*/
|
|
10
|
+
var Approval_exports = /* @__PURE__ */ __exportAll({
|
|
11
|
+
ApprovalId: () => ApprovalId,
|
|
12
|
+
Model: () => Model,
|
|
13
|
+
decode: () => decode,
|
|
14
|
+
encode: () => encode
|
|
15
|
+
});
|
|
16
|
+
const ApprovalId = Schema.String.pipe(Schema.brand("@domainkit/ApprovalId"));
|
|
17
|
+
var Model = class extends Schema.Class("@domainkit/Approval")({
|
|
18
|
+
id: ApprovalId,
|
|
19
|
+
version: Schema.Literal("domainkit.approval.v2"),
|
|
20
|
+
kind: Kind,
|
|
21
|
+
planId: PlanId,
|
|
22
|
+
digest: Digest,
|
|
23
|
+
/** Subset of the plan's write operations; the full set unless partial approval was requested. */
|
|
24
|
+
operationIds: Schema.Array(OperationId),
|
|
25
|
+
approvedBy: Schema.String,
|
|
26
|
+
approvedAt: Schema.DateTimeUtcFromString,
|
|
27
|
+
expiresAt: Schema.DateTimeUtcFromString
|
|
28
|
+
}) {};
|
|
29
|
+
const decode = (input) => decode$1(Model, input);
|
|
30
|
+
const encode = Schema.encodeSync(Model);
|
|
31
|
+
//#endregion
|
|
32
|
+
export { ApprovalId, Approval_exports, Model, decode, encode };
|
|
33
|
+
|
|
34
|
+
//# sourceMappingURL=Approval.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Approval.mjs","names":["Plan.Kind","Plan.PlanId","Plan.Digest","Plan.OperationId","Errors.decode"],"sources":["../src/Approval.ts"],"sourcesContent":["/**\n * A principal's consent to apply a specific plan digest. `Provision.apply` and `Cleanup.apply`\n * accept only an Approval. The word \"authorization\" is reserved for provider OAuth.\n */\nimport { Effect, Schema } from \"effect\";\n\nimport * as Errors from \"./internal/error.ts\";\nimport * as Plan from \"./Plan.ts\";\n\nexport const ApprovalId = Schema.String.pipe(Schema.brand(\"@domainkit/ApprovalId\"));\nexport type ApprovalId = typeof ApprovalId.Type;\n\nexport class Model extends Schema.Class<Model>(\"@domainkit/Approval\")({\n id: ApprovalId,\n version: Schema.Literal(\"domainkit.approval.v2\"),\n kind: Plan.Kind,\n planId: Plan.PlanId,\n digest: Plan.Digest,\n /** Subset of the plan's write operations; the full set unless partial approval was requested. */\n operationIds: Schema.Array(Plan.OperationId),\n approvedBy: Schema.String,\n approvedAt: Schema.DateTimeUtcFromString,\n expiresAt: Schema.DateTimeUtcFromString,\n}) {}\nexport type Encoded = typeof Model.Encoded;\n\nexport const decode = (input: unknown): Effect.Effect<Model, Errors.DomainKitError> =>\n Errors.decode(Model, input);\nexport const encode: (approval: Model) => Encoded = Schema.encodeSync(Model);\n"],"mappings":";;;;;;;;;;;;;;;AASA,MAAa,aAAa,OAAO,OAAO,KAAK,OAAO,MAAM,uBAAuB,CAAC;AAGlF,IAAa,QAAb,cAA2B,OAAO,MAAa,qBAAqB,CAAC,CAAC;CACpE,IAAI;CACJ,SAAS,OAAO,QAAQ,uBAAuB;CAC/C,MAAMA;CACN,QAAQC;CACR,QAAQC;;CAER,cAAc,OAAO,MAAMC,WAAgB;CAC3C,YAAY,OAAO;CACnB,YAAY,OAAO;CACnB,WAAW,OAAO;AACpB,CAAC,CAAC,CAAC,CAAC;AAGJ,MAAa,UAAU,UACrBC,SAAc,OAAO,KAAK;AAC5B,MAAa,SAAuC,OAAO,WAAW,KAAK"}
|
package/dist/Cleanup.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { __exportAll } from "./_virtual/_rolldown/runtime.mjs";
|
|
2
|
+
import { decode } from "./DomainName.mjs";
|
|
3
|
+
import { InvalidInput, NotFound } from "./Reason.mjs";
|
|
4
|
+
import { fail } from "./internal/error.mjs";
|
|
5
|
+
import { applied } from "./Receipt.mjs";
|
|
6
|
+
import { Service as Service$1 } from "./Storage.mjs";
|
|
7
|
+
import { Service as Service$2 } from "./Connect.mjs";
|
|
8
|
+
import { assertWithin, cleanupOperation, make as make$1 } from "./internal/attempts.mjs";
|
|
9
|
+
import { Policy } from "./Provision.mjs";
|
|
10
|
+
import { Context, Effect, Layer, Option } from "effect";
|
|
11
|
+
//#region src/Cleanup.ts
|
|
12
|
+
/**
|
|
13
|
+
* Remove only what a receipt proves DomainKit created, with its own plan, approval, and receipt.
|
|
14
|
+
* Same three verbs as Provision; the plan is built from a receipt instead of requirements.
|
|
15
|
+
*/
|
|
16
|
+
var Cleanup_exports = /* @__PURE__ */ __exportAll({
|
|
17
|
+
Service: () => Service,
|
|
18
|
+
apply: () => apply,
|
|
19
|
+
approve: () => approve,
|
|
20
|
+
layer: () => layer,
|
|
21
|
+
make: () => make,
|
|
22
|
+
plan: () => plan,
|
|
23
|
+
reject: () => reject
|
|
24
|
+
});
|
|
25
|
+
var Service = class extends Context.Service()("@domainkit/Cleanup") {};
|
|
26
|
+
const make = Effect.gen(function* () {
|
|
27
|
+
const storage = yield* Service$1;
|
|
28
|
+
const connect = yield* Service$2;
|
|
29
|
+
const attempts = make$1(storage, connect, "cleanup");
|
|
30
|
+
const sourceAttempt = (input) => Effect.gen(function* () {
|
|
31
|
+
if ("receiptId" in input) {
|
|
32
|
+
const attempt = yield* storage.attempts.byReceipt(input.receiptId);
|
|
33
|
+
if (attempt.kind !== "provisioning" || attempt.receipt === null) return yield* fail(new NotFound({
|
|
34
|
+
entity: "receipt",
|
|
35
|
+
id: input.receiptId
|
|
36
|
+
}));
|
|
37
|
+
return attempt;
|
|
38
|
+
}
|
|
39
|
+
const domain = yield* decode(input.domain);
|
|
40
|
+
const attachment = yield* storage.attachments.byDomain(domain);
|
|
41
|
+
const latest = Option.isSome(attachment) ? yield* storage.attempts.latest(attachment.value.id, "provisioning") : Option.none();
|
|
42
|
+
if (Option.isNone(latest) || latest.value.receipt === null) return yield* fail(new NotFound({
|
|
43
|
+
entity: "receipt",
|
|
44
|
+
id: domain
|
|
45
|
+
}));
|
|
46
|
+
return latest.value;
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
plan: (input) => Effect.gen(function* () {
|
|
50
|
+
const policy = yield* Policy;
|
|
51
|
+
const source = yield* sourceAttempt(input);
|
|
52
|
+
const receipt = source.receipt;
|
|
53
|
+
if (receipt === null) return yield* fail(new NotFound({
|
|
54
|
+
entity: "receipt",
|
|
55
|
+
id: source.id
|
|
56
|
+
}));
|
|
57
|
+
const attachment = yield* storage.attachments.get(source.attachmentId);
|
|
58
|
+
yield* assertWithin(attachment, source.plan.operations.map((operation) => operation.record));
|
|
59
|
+
const { session, target } = yield* connect.session(attachment.id);
|
|
60
|
+
const dns = session.dns(target);
|
|
61
|
+
const operations = yield* Effect.forEach(applied(receipt), (applied) => Effect.gen(function* () {
|
|
62
|
+
const created = source.plan.operations.find((operation) => operation._tag === "Create" && operation.id === applied.operationId);
|
|
63
|
+
if (created === void 0) return yield* fail(new InvalidInput({
|
|
64
|
+
message: `Receipt ${receipt.id} proves no created record for ${applied.operationId}`,
|
|
65
|
+
field: "receiptId"
|
|
66
|
+
}));
|
|
67
|
+
return yield* cleanupOperation(dns, target.zone, created.id, created.record, applied.providerRecordId);
|
|
68
|
+
}));
|
|
69
|
+
return yield* attempts.record({
|
|
70
|
+
attachment,
|
|
71
|
+
target,
|
|
72
|
+
operations,
|
|
73
|
+
ttlMs: policy.planTtlMs,
|
|
74
|
+
sourceReceiptId: receipt.id
|
|
75
|
+
});
|
|
76
|
+
}),
|
|
77
|
+
approve: (plan, options) => attempts.approve(plan, options),
|
|
78
|
+
reject: (plan, options) => Effect.map(attempts.reject(plan, options), (attempt) => ({
|
|
79
|
+
plan: attempt.plan,
|
|
80
|
+
status: attempt.status,
|
|
81
|
+
approval: attempt.approval,
|
|
82
|
+
receipt: attempt.receipt,
|
|
83
|
+
rejection: attempt.rejection
|
|
84
|
+
})),
|
|
85
|
+
apply: (approval) => Effect.flatMap(Policy, (policy) => attempts.apply(approval, policy))
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
const layer = Layer.effect(Service)(make);
|
|
89
|
+
const accessor = (pick) => (...args) => Effect.flatMap(Service, (service) => pick(service)(...args));
|
|
90
|
+
const plan = accessor((service) => service.plan);
|
|
91
|
+
const approve = accessor((service) => service.approve);
|
|
92
|
+
const reject = accessor((service) => service.reject);
|
|
93
|
+
const apply = accessor((service) => service.apply);
|
|
94
|
+
//#endregion
|
|
95
|
+
export { Cleanup_exports, Service, apply, approve, layer, make, plan, reject };
|
|
96
|
+
|
|
97
|
+
//# sourceMappingURL=Cleanup.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Cleanup.mjs","names":["Storage.Service","Connect.Service","Attempts.make","Errors.fail","Reason.NotFound","DomainName.decode","Provision.Policy","Attempts.assertWithin","Receipt.applied","Reason.InvalidInput","Attempts.cleanupOperation"],"sources":["../src/Cleanup.ts"],"sourcesContent":["/**\n * Remove only what a receipt proves DomainKit created, with its own plan, approval, and receipt.\n * Same three verbs as Provision; the plan is built from a receipt instead of requirements.\n */\nimport { Context, Effect, Layer, Option } from \"effect\";\n\nimport type * as Approval from \"./Approval.ts\";\nimport * as Connect from \"./Connect.ts\";\nimport * as Errors from \"./internal/error.ts\";\nimport * as Reason from \"./Reason.ts\";\nimport * as DomainName from \"./DomainName.ts\";\nimport * as Attempts from \"./internal/attempts.ts\";\nimport type * as Plan from \"./Plan.ts\";\nimport type * as Principal from \"./Principal.ts\";\nimport * as Provision from \"./Provision.ts\";\nimport * as Receipt from \"./Receipt.ts\";\nimport * as Storage from \"./Storage.ts\";\n\ntype Fx<A> = Effect.Effect<A, Errors.DomainKitError, Principal.Service>;\n\nexport interface Interface {\n /** Read back every receipt record; records that no longer match exactly become `Conflict`. */\n readonly plan: (\n input: { readonly receiptId: Receipt.ReceiptId } | { readonly domain: string },\n ) => Fx<Plan.Model>;\n readonly approve: (\n plan: Plan.Model | Plan.PlanId,\n options?: Attempts.ApproveOptions,\n ) => Fx<Approval.Model>;\n /** Decline the cleanup plan; terminal, same rules as `Provision.reject`. */\n readonly reject: (\n plan: Plan.Model | Plan.PlanId,\n options?: Provision.RejectOptions,\n ) => Fx<Provision.Attempt>;\n readonly apply: (approval: Approval.Model | Approval.ApprovalId) => Fx<Receipt.Model>;\n}\n\nexport class Service extends Context.Service<Service, Interface>()(\"@domainkit/Cleanup\") {}\n\nexport const make: Effect.Effect<Interface, never, Storage.Service | Connect.Service> = Effect.gen(\n function* () {\n const storage = yield* Storage.Service;\n const connect = yield* Connect.Service;\n const attempts = Attempts.make(storage, connect, \"cleanup\");\n\n const sourceAttempt = (\n input: { readonly receiptId: Receipt.ReceiptId } | { readonly domain: string },\n ) =>\n Effect.gen(function* () {\n if (\"receiptId\" in input) {\n const attempt = yield* storage.attempts.byReceipt(input.receiptId);\n if (attempt.kind !== \"provisioning\" || attempt.receipt === null) {\n return yield* Errors.fail(\n new Reason.NotFound({ entity: \"receipt\", id: input.receiptId }),\n );\n }\n return attempt;\n }\n const domain = yield* DomainName.decode(input.domain);\n const attachment = yield* storage.attachments.byDomain(domain);\n const latest = Option.isSome(attachment)\n ? yield* storage.attempts.latest(attachment.value.id, \"provisioning\")\n : Option.none<Storage.Attempt>();\n if (Option.isNone(latest) || latest.value.receipt === null) {\n return yield* Errors.fail(new Reason.NotFound({ entity: \"receipt\", id: domain }));\n }\n return latest.value;\n });\n\n return {\n plan: (input) =>\n Effect.gen(function* () {\n const policy = yield* Provision.Policy;\n const source = yield* sourceAttempt(input);\n const receipt = source.receipt;\n if (receipt === null) {\n return yield* Errors.fail(new Reason.NotFound({ entity: \"receipt\", id: source.id }));\n }\n const attachment = yield* storage.attachments.get(source.attachmentId);\n yield* Attempts.assertWithin(\n attachment,\n source.plan.operations.map((operation) => operation.record),\n );\n const { session, target } = yield* connect.session(attachment.id);\n const dns = session.dns(target);\n const operations = yield* Effect.forEach(Receipt.applied(receipt), (applied) =>\n Effect.gen(function* () {\n const created = source.plan.operations.find(\n (operation) => operation._tag === \"Create\" && operation.id === applied.operationId,\n );\n if (created === undefined) {\n return yield* Errors.fail(\n new Reason.InvalidInput({\n message: `Receipt ${receipt.id} proves no created record for ${applied.operationId}`,\n field: \"receiptId\",\n }),\n );\n }\n return yield* Attempts.cleanupOperation(\n dns,\n target.zone,\n created.id,\n created.record,\n applied.providerRecordId,\n );\n }),\n );\n return yield* attempts.record({\n attachment,\n target,\n operations,\n ttlMs: policy.planTtlMs,\n sourceReceiptId: receipt.id,\n });\n }),\n approve: (plan, options) => attempts.approve(plan, options),\n reject: (plan, options) =>\n Effect.map(attempts.reject(plan, options), (attempt) => ({\n plan: attempt.plan,\n status: attempt.status,\n approval: attempt.approval,\n receipt: attempt.receipt,\n rejection: attempt.rejection,\n })),\n apply: (approval) =>\n Effect.flatMap(Provision.Policy, (policy) => attempts.apply(approval, policy)),\n };\n },\n);\n\nexport const layer: Layer.Layer<Service, never, Storage.Service | Connect.Service> =\n Layer.effect(Service)(make);\n\nconst accessor =\n <Args extends ReadonlyArray<unknown>, A>(\n pick: (service: Interface) => (...args: Args) => Fx<A>,\n ): ((...args: Args) => Effect.Effect<A, Errors.DomainKitError, Principal.Service | Service>) =>\n (...args) =>\n Effect.flatMap(Service, (service) => pick(service)(...args));\n\nexport const plan = accessor((service) => service.plan);\nexport const approve = accessor((service) => service.approve);\nexport const reject = accessor((service) => service.reject);\nexport const apply = accessor((service) => service.apply);\n"],"mappings":"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"}
|