@homeflare/alchemy 0.48.0 → 0.49.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.
Files changed (97) hide show
  1. package/README.md +11 -0
  2. package/dist/cloudflare/cloudflared-tunnel-api.d.ts +87 -0
  3. package/dist/cloudflare/cloudflared-tunnel-api.d.ts.map +1 -0
  4. package/dist/cloudflare/cloudflared-tunnel-form.d.ts +49 -0
  5. package/dist/cloudflare/cloudflared-tunnel-form.d.ts.map +1 -0
  6. package/dist/cloudflare/cloudflared-tunnel-harness.d.ts +45 -0
  7. package/dist/cloudflare/cloudflared-tunnel-harness.d.ts.map +1 -0
  8. package/dist/cloudflare/cloudflared-tunnel-lifecycle.d.ts +21 -0
  9. package/dist/cloudflare/cloudflared-tunnel-lifecycle.d.ts.map +1 -0
  10. package/dist/cloudflare/cloudflared-tunnel.d.ts +39 -0
  11. package/dist/cloudflare/cloudflared-tunnel.d.ts.map +1 -0
  12. package/dist/cloudflare/fake-access.d.ts +40 -0
  13. package/dist/cloudflare/fake-access.d.ts.map +1 -0
  14. package/dist/cloudflare/fake-mesh.d.ts +7 -2
  15. package/dist/cloudflare/fake-mesh.d.ts.map +1 -1
  16. package/dist/cloudflare/fake-tunnel.d.ts +48 -0
  17. package/dist/cloudflare/fake-tunnel.d.ts.map +1 -0
  18. package/dist/cloudflare/index.d.ts +15 -4
  19. package/dist/cloudflare/index.d.ts.map +1 -1
  20. package/dist/cloudflare/index.js +518 -7
  21. package/dist/cloudflare/index.js.map +15 -4
  22. package/dist/cloudflare/providers.d.ts +1 -1
  23. package/dist/cloudflare/providers.d.ts.map +1 -1
  24. package/dist/cloudflare/saas-oidc-api.d.ts +88 -0
  25. package/dist/cloudflare/saas-oidc-api.d.ts.map +1 -0
  26. package/dist/cloudflare/saas-oidc-consumer.d.ts +22 -0
  27. package/dist/cloudflare/saas-oidc-consumer.d.ts.map +1 -0
  28. package/dist/cloudflare/saas-oidc-error.d.ts +22 -0
  29. package/dist/cloudflare/saas-oidc-error.d.ts.map +1 -0
  30. package/dist/cloudflare/saas-oidc-form.d.ts +48 -0
  31. package/dist/cloudflare/saas-oidc-form.d.ts.map +1 -0
  32. package/dist/cloudflare/saas-oidc-harness.d.ts +50 -0
  33. package/dist/cloudflare/saas-oidc-harness.d.ts.map +1 -0
  34. package/dist/cloudflare/saas-oidc-lifecycle.d.ts +14 -0
  35. package/dist/cloudflare/saas-oidc-lifecycle.d.ts.map +1 -0
  36. package/dist/cloudflare/saas-oidc-props.d.ts +139 -0
  37. package/dist/cloudflare/saas-oidc-props.d.ts.map +1 -0
  38. package/dist/cloudflare/saas-oidc-team.d.ts +9 -0
  39. package/dist/cloudflare/saas-oidc-team.d.ts.map +1 -0
  40. package/dist/cloudflare/saas-oidc-wire.d.ts +58 -0
  41. package/dist/cloudflare/saas-oidc-wire.d.ts.map +1 -0
  42. package/dist/cloudflare/saas-oidc.d.ts +36 -0
  43. package/dist/cloudflare/saas-oidc.d.ts.map +1 -0
  44. package/dist/talos/cluster-adapter-errors.d.ts.map +1 -1
  45. package/dist/talos/cluster-adapter.d.ts +2 -1
  46. package/dist/talos/cluster-adapter.d.ts.map +1 -1
  47. package/dist/talos/index.js +19 -8
  48. package/dist/talos/index.js.map +6 -6
  49. package/dist/talos/mint-temp-file.d.ts.map +1 -1
  50. package/dist/talos/talosctl.d.ts +29 -0
  51. package/dist/talos/talosctl.d.ts.map +1 -1
  52. package/docs/cloudflared-tunnel.md +136 -0
  53. package/docs/credentials.md +1 -1
  54. package/docs/ownership.md +3 -2
  55. package/docs/saas-oidc.md +185 -0
  56. package/docs/talos-openbao-adapter.md +3 -2
  57. package/docs/upstream-conformance-families.md +1 -1
  58. package/docs/upstream-conformance.md +3 -0
  59. package/package.json +1 -1
  60. package/src/cloudflare/cloudflared-tunnel-api.ts +150 -0
  61. package/src/cloudflare/cloudflared-tunnel-edges.test.ts +179 -0
  62. package/src/cloudflare/cloudflared-tunnel-form.ts +78 -0
  63. package/src/cloudflare/cloudflared-tunnel-harness.ts +126 -0
  64. package/src/cloudflare/cloudflared-tunnel-lifecycle.ts +146 -0
  65. package/src/cloudflare/cloudflared-tunnel-state.test.ts +104 -0
  66. package/src/cloudflare/cloudflared-tunnel.test.ts +164 -0
  67. package/src/cloudflare/cloudflared-tunnel.ts +116 -0
  68. package/src/cloudflare/fake-access.ts +196 -0
  69. package/src/cloudflare/fake-mesh.ts +6 -3
  70. package/src/cloudflare/fake-tunnel.ts +148 -0
  71. package/src/cloudflare/index.ts +27 -4
  72. package/src/cloudflare/providers.ts +16 -3
  73. package/src/cloudflare/saas-oidc-api.ts +224 -0
  74. package/src/cloudflare/saas-oidc-consumer.ts +35 -0
  75. package/src/cloudflare/saas-oidc-error.ts +52 -0
  76. package/src/cloudflare/saas-oidc-form.test.ts +227 -0
  77. package/src/cloudflare/saas-oidc-form.ts +190 -0
  78. package/src/cloudflare/saas-oidc-harness.ts +140 -0
  79. package/src/cloudflare/saas-oidc-lifecycle.ts +150 -0
  80. package/src/cloudflare/saas-oidc-parse-error.test.ts +180 -0
  81. package/src/cloudflare/saas-oidc-props.ts +147 -0
  82. package/src/cloudflare/saas-oidc-read.test.ts +85 -0
  83. package/src/cloudflare/saas-oidc-stable.test.ts +38 -0
  84. package/src/cloudflare/saas-oidc-state.test.ts +128 -0
  85. package/src/cloudflare/saas-oidc-team.test.ts +88 -0
  86. package/src/cloudflare/saas-oidc-team.ts +70 -0
  87. package/src/cloudflare/saas-oidc-wire.ts +116 -0
  88. package/src/cloudflare/saas-oidc.test.ts +201 -0
  89. package/src/cloudflare/saas-oidc.ts +107 -0
  90. package/src/talos/cluster-adapter-errors.ts +2 -1
  91. package/src/talos/cluster-adapter-move.test.ts +2 -0
  92. package/src/talos/cluster-adapter.ts +2 -1
  93. package/src/talos/mint-temp-file.test.ts +44 -0
  94. package/src/talos/mint-temp-file.ts +16 -4
  95. package/src/talos/talosctl-probe-kill.test.ts +56 -0
  96. package/src/talos/talosctl.test.ts +54 -1
  97. package/src/talos/talosctl.ts +34 -2
@@ -0,0 +1,190 @@
1
+ /**
2
+ * `HomeFlare.Access.SaasOidcApplication` — the pure helpers the lifecycle uses: validation, the
3
+ * issuer URLs, drift, the write body and the attribute mapping. The props and attributes types are
4
+ * declared in saas-oidc-props.ts and re-exported here.
5
+ *
6
+ * ⛔ THE ATTRIBUTES NEVER HOLD THE CLIENT SECRET, and nothing here can: `ObservedApp`
7
+ * (saas-oidc-api.ts) has no field for it. A client that authenticates with PKCE alone declares
8
+ * `allowPkceWithoutClientSecret: true`, and then no secret exists to keep.
9
+ * ★ THE ISSUER IS `https://<teamDomain>/cdn-cgi/access/sso/oidc/<clientId>`. The team domain is a
10
+ * prop; the openbao copy this came from hardcoded one account's. Cloudflare also reports the
11
+ * same host in the app's own `domain` field (Alchemy's `Access.Application` doc: for `saas`
12
+ * "Cloudflare uses the OIDC issuer"), and `checkTeam` refuses a declaration that disagrees with it.
13
+ */
14
+ import { SaasOidcError } from './saas-oidc-error.ts';
15
+ import type { SaasOidcApplicationAttributes, SaasOidcApplicationProps } from './saas-oidc-props.ts';
16
+ import type { AppWrite, ObservedApp } from './saas-oidc-wire.ts';
17
+
18
+ // The declared shape lives in saas-oidc-props.ts (every field documented there); re-exported so
19
+ // the module that always held it keeps answering to its old name.
20
+ export type {
21
+ OidcGrantType,
22
+ OidcScope,
23
+ SaasOidcApplicationAttributes,
24
+ SaasOidcApplicationProps,
25
+ } from './saas-oidc-props.ts';
26
+
27
+ const HOSTNAME = /^[a-z0-9]+(?:[.-][a-z0-9]+)+$/;
28
+
29
+ /** ⛔ Fail closed on a declaration the API would accept but nobody meant. */
30
+ export const validateSaasOidc = (props: SaasOidcApplicationProps): SaasOidcError | undefined => {
31
+ const fail = (message: string) => new SaasOidcError({ message });
32
+ if (typeof props.teamDomain !== 'string' || !HOSTNAME.test(props.teamDomain)) {
33
+ return fail(
34
+ `SaasOidcApplication \`teamDomain\` must be a bare lowercase hostname such as "example.cloudflareaccess.com" (no scheme, no path); got ${JSON.stringify(props.teamDomain)}.`,
35
+ );
36
+ }
37
+ if (props.name !== undefined && props.name.trim() === '') {
38
+ return fail('SaasOidcApplication `name`, when set, must not be empty.');
39
+ }
40
+ if (
41
+ props.saasApp.allowPkceWithoutClientSecret === true &&
42
+ !props.saasApp.grantTypes.includes('authorization_code_with_pkce')
43
+ ) {
44
+ return fail(
45
+ '`allowPkceWithoutClientSecret: true` only applies to the `authorization_code_with_pkce` grant; add it to `grantTypes` or drop the flag.',
46
+ );
47
+ }
48
+ return undefined;
49
+ };
50
+
51
+ export const issuerFor = (teamDomain: string, clientId: string): string =>
52
+ `https://${teamDomain}/cdn-cgi/access/sso/oidc/${clientId}`;
53
+
54
+ const hostOf = (domain: string): string => domain.split('/')[0]?.toLowerCase() ?? '';
55
+
56
+ /**
57
+ * ⛔ A DECLARED TEAM DOMAIN THAT DISAGREES WITH THE APP'S OWN `domain` is refused: the issuer in
58
+ * the attributes would point at a different team than the one that holds the app.
59
+ * ⛔ WHEN CLOUDFLARE REPORTS NO `domain` there is nothing to compare, so the team the row already
60
+ * recorded (`knownTeam`, from state) stands in: a declaration that moves it is refused, because
61
+ * `issuer` is a declared stable and must not move under the row. With no `knownTeam` either
62
+ * (a first create or a cold adoption) there is nothing to compare against and the declaration
63
+ * stands.
64
+ */
65
+ export const checkTeam = (
66
+ teamDomain: string,
67
+ app: ObservedApp,
68
+ knownTeam?: string,
69
+ ): SaasOidcError | undefined => {
70
+ if (app.domain === undefined) {
71
+ return knownTeam === undefined || knownTeam.toLowerCase() === teamDomain.toLowerCase()
72
+ ? undefined
73
+ : new SaasOidcError({
74
+ message: `SaasOidcApplication declares teamDomain "${teamDomain}" but the row recorded "${knownTeam}" and Cloudflare reports no domain for the app to confirm the change. The issuer cannot move; revert the declaration.`,
75
+ });
76
+ }
77
+ return hostOf(app.domain) === teamDomain.toLowerCase()
78
+ ? undefined
79
+ : new SaasOidcError({
80
+ message: `SaasOidcApplication declares teamDomain "${teamDomain}" but Cloudflare reports the app on "${hostOf(app.domain)}".`,
81
+ });
82
+ };
83
+
84
+ const refreshLifetime = (props: SaasOidcApplicationProps): string =>
85
+ props.saasApp.refreshTokenLifetime ?? '';
86
+
87
+ /** The request body for a create or an update. */
88
+ export const writeBody = (news: SaasOidcApplicationProps, name: string): AppWrite => ({
89
+ name,
90
+ sessionDuration: news.sessionDuration ?? '24h',
91
+ allowedIdps: news.allowedIdps,
92
+ autoRedirectToIdentity: news.autoRedirectToIdentity,
93
+ appLauncherVisible: news.appLauncherVisible ?? false,
94
+ policies: news.policies,
95
+ saasApp: {
96
+ authType: 'oidc',
97
+ redirectUris: news.saasApp.redirectUris,
98
+ scopes: news.saasApp.scopes,
99
+ grantTypes: news.saasApp.grantTypes,
100
+ accessTokenLifetime: news.saasApp.accessTokenLifetime,
101
+ ...(news.saasApp.refreshTokenLifetime === undefined
102
+ ? {}
103
+ : { refreshTokenOptions: { lifetime: news.saasApp.refreshTokenLifetime } }),
104
+ allowPkceWithoutClientSecret: news.saasApp.allowPkceWithoutClientSecret ?? false,
105
+ appLauncherUrl: news.saasApp.appLauncherUrl,
106
+ },
107
+ });
108
+
109
+ const sameSet = (a: ReadonlyArray<string>, b: ReadonlyArray<string> | undefined): boolean =>
110
+ JSON.stringify([...a].sort()) === JSON.stringify([...(b ?? [])].sort());
111
+
112
+ /**
113
+ * ⛔ ORDER MATTERS FOR POLICIES: `policies` is "in ascending order of precedence" (the SDK's own
114
+ * field doc, zero_trust.ts:9774), so `[a, b]` and `[b, a]` are different declarations. A set
115
+ * compare here would let a reorder go unnoticed and leave the live precedence unchanged.
116
+ * `parseApp` hands the observed ids over already ordered by the API's `precedence`.
117
+ */
118
+ const sameList = (a: ReadonlyArray<string>, b: ReadonlyArray<string> | undefined): boolean => {
119
+ const other = b ?? [];
120
+ return a.length === other.length && a.every((item, index) => item === other[index]);
121
+ };
122
+
123
+ /**
124
+ * Does the live app differ from the declaration? Compared: name, auth type, redirect URIs, scopes,
125
+ * grant types (each as a set), both lifetimes, the PKCE flag and the policies IN ORDER — the
126
+ * openbao copy's set plus the policy order, kept so adopting a live app is not newly a write.
127
+ * ⚠️ NOT COMPARED: `sessionDuration`, `allowedIdps`, `autoRedirectToIdentity`, `appLauncherVisible`,
128
+ * `appLauncherUrl`. They are written on create and on any sync, but a change to only those does
129
+ * not trigger one. Carried over unchanged; see saas-oidc.md.
130
+ */
131
+ export const needsSync = (
132
+ news: SaasOidcApplicationProps,
133
+ observed: ObservedApp,
134
+ name: string,
135
+ ): boolean => {
136
+ const saas = observed.saasApp;
137
+ return (
138
+ (observed.name ?? '') !== name ||
139
+ (saas?.authType ?? '') !== 'oidc' ||
140
+ !sameSet(news.saasApp.redirectUris, saas?.redirectUris) ||
141
+ !sameSet(news.saasApp.scopes, saas?.scopes) ||
142
+ !sameSet(news.saasApp.grantTypes, saas?.grantTypes) ||
143
+ (saas?.accessTokenLifetime ?? '') !== news.saasApp.accessTokenLifetime ||
144
+ refreshLifetime(news) !== (saas?.refreshTokenLifetime ?? '') ||
145
+ (saas?.allowPkceWithoutClientSecret ?? false) !==
146
+ (news.saasApp.allowPkceWithoutClientSecret ?? false) ||
147
+ !sameList(news.policies, observed.policyIds)
148
+ );
149
+ };
150
+
151
+ /**
152
+ * The attributes of a live app, or `undefined` when it lacks an id, an `aud` or a client id (so it
153
+ * is not a SaaS OIDC app this package can describe). `teamDomain` is the declared one, or, for a
154
+ * read that has no declaration (state written before the prop existed), the host Cloudflare reports.
155
+ */
156
+ export const toAttributes = (
157
+ app: ObservedApp,
158
+ accountId: string,
159
+ fallbackName: string,
160
+ declaredTeam: string | undefined,
161
+ ): SaasOidcApplicationAttributes | undefined => {
162
+ const clientId = app.saasApp?.clientId;
163
+ const teamDomain = declaredTeam ?? (app.domain === undefined ? undefined : hostOf(app.domain));
164
+ if (app.id === undefined || app.aud === undefined || clientId === undefined) return undefined;
165
+ if (teamDomain === undefined || teamDomain === '') return undefined;
166
+ const issuer = issuerFor(teamDomain, clientId);
167
+ const saas = app.saasApp;
168
+ return {
169
+ applicationId: app.id,
170
+ aud: app.aud,
171
+ domain: app.domain ?? issuer.replace('https://', ''),
172
+ name: app.name ?? fallbackName,
173
+ accountId,
174
+ teamDomain,
175
+ clientId,
176
+ issuer,
177
+ authorizationEndpoint: `${issuer}/authorization`,
178
+ tokenEndpoint: `${issuer}/token`,
179
+ jwksEndpoint: `${issuer}/jwks`,
180
+ userinfoEndpoint: `${issuer}/userinfo`,
181
+ configurationEndpoint: `${issuer}/.well-known/openid-configuration`,
182
+ redirectUris: saas?.redirectUris ?? [],
183
+ scopes: saas?.scopes ?? [],
184
+ grantTypes: saas?.grantTypes ?? [],
185
+ accessTokenLifetime: saas?.accessTokenLifetime ?? '',
186
+ refreshTokenLifetime: saas?.refreshTokenLifetime ?? '',
187
+ createdAt: app.createdAt,
188
+ updatedAt: app.updatedAt,
189
+ };
190
+ };
@@ -0,0 +1,140 @@
1
+ /**
2
+ * SaasOidcApplicationProvider's handlers, called the way the engine calls them, over the fake
3
+ * Access API in fake-access.ts — and `engine`, which runs Alchemy's real plan/apply over the same
4
+ * fake and exposes the in-memory state rows. Shared by saas-oidc.test.ts and
5
+ * saas-oidc-state.test.ts. ⛔ TEST-ONLY, like fake-access.ts.
6
+ */
7
+ import { apply } from 'alchemy/Apply';
8
+ import * as Plan from 'alchemy/Plan';
9
+ import { type CompiledStack, make as makeStack } from 'alchemy/Stack';
10
+ import { Stage } from 'alchemy/Stage';
11
+ import * as State from 'alchemy/State';
12
+ import * as Cause from 'effect/Cause';
13
+ import * as Context from 'effect/Context';
14
+ import * as Effect from 'effect/Effect';
15
+ import * as Exit from 'effect/Exit';
16
+ import * as Layer from 'effect/Layer';
17
+ import * as FetchHttpClient from 'effect/http/FetchHttpClient';
18
+ import { type FakeAccess, TEAM } from './fake-access.ts';
19
+ import { fakeProviderLayer } from './fake-mesh.ts';
20
+ import type { SaasOidcApplicationAttributes, SaasOidcApplicationProps } from './saas-oidc-form.ts';
21
+ import { SaasOidcApplication, SaasOidcApplicationProvider } from './saas-oidc.ts';
22
+
23
+ /**
24
+ * ⚠️ THE SDK'S STRICT VALIDATION SWITCH, REBUILT BY KEY. A `CloudflareParseError` exists only under
25
+ * `ResponseValidation.strict` (`@distilled.cloud/core` response-validation.ts:43-52, :104-116);
26
+ * the default is lenient. `@distilled.cloud/core` is not a dependency of this package, so this
27
+ * declares a reference with the SAME string key and provides `'strict'` for it: the SDK reads the
28
+ * mode from the fiber's context by that key. saas-oidc-parse-error.test.ts first proves the raw
29
+ * SDK really fails under it, so a renamed key fails that test instead of passing vacuously.
30
+ */
31
+ const ResponseValidation = Context.Reference<'lenient' | 'strict'>(
32
+ '@distilled.cloud/core/ResponseValidation',
33
+ { defaultValue: () => 'lenient' },
34
+ );
35
+ export const strict = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
36
+ effect.pipe(Effect.provideService(ResponseValidation, 'strict'));
37
+
38
+ export const ids = { fqn: 'stack/headlamp', id: 'headlamp', instanceId: 'i-1' };
39
+ export const extra = { bindings: [] as never, session: undefined as never };
40
+
41
+ export type P = Effect.Success<typeof SaasOidcApplication.Provider>;
42
+
43
+ /** A public PKCE client, the shape Headlamp declares (Q6). */
44
+ export const publicClient = (
45
+ over: Partial<SaasOidcApplicationProps> = {},
46
+ ): SaasOidcApplicationProps => ({
47
+ name: 'Headlamp',
48
+ teamDomain: TEAM,
49
+ policies: ['policy-admin'],
50
+ saasApp: {
51
+ authType: 'oidc',
52
+ redirectUris: ['https://headlamp.example.test/oidc-callback'],
53
+ scopes: ['openid', 'email', 'groups'],
54
+ grantTypes: ['authorization_code_with_pkce'],
55
+ accessTokenLifetime: '15m',
56
+ allowPkceWithoutClientSecret: true,
57
+ },
58
+ ...over,
59
+ });
60
+
61
+ /** The wire `saas_app` of a live app that matches `publicClient()` exactly (no drift to sync). */
62
+ export const LIVE_OIDC = {
63
+ auth_type: 'oidc',
64
+ redirect_uris: ['https://headlamp.example.test/oidc-callback'],
65
+ scopes: ['openid', 'email', 'groups'],
66
+ grant_types: ['authorization_code_with_pkce'],
67
+ access_token_lifetime: '15m',
68
+ allow_pkce_without_client_secret: true,
69
+ };
70
+
71
+ /** Build the provider over the fake and hand it to `use`. */
72
+ export const run = <A, E>(fake: FakeAccess, use: (p: P) => Effect.Effect<A, E, never>) =>
73
+ Effect.runPromise(
74
+ Effect.gen(function* () {
75
+ return yield* use(yield* SaasOidcApplication.Provider);
76
+ }).pipe(
77
+ Effect.provide(
78
+ SaasOidcApplicationProvider().pipe(Layer.provideMerge(fakeProviderLayer(fake))),
79
+ ),
80
+ // `createPhysicalName` reads these from the engine's context; a named app never calls it.
81
+ Effect.provideService(FetchHttpClient.Fetch, fake.fetch),
82
+ ) as Effect.Effect<A, E, never>,
83
+ );
84
+
85
+ export const reconcile = (
86
+ p: P,
87
+ news: SaasOidcApplicationProps,
88
+ output?: SaasOidcApplicationAttributes,
89
+ ) => p.reconcile({ ...ids, ...extra, news, olds: undefined, output });
90
+
91
+ export const read = (
92
+ p: P,
93
+ olds: SaasOidcApplicationProps,
94
+ output?: SaasOidcApplicationAttributes,
95
+ ) => {
96
+ const handler = p.read;
97
+ if (handler === undefined) throw new Error('provider has no read handler');
98
+ return handler({ ...ids, olds, output });
99
+ };
100
+
101
+ /** The writes the fake saw, in order: `['POST', 'PUT', …]`. */
102
+ export const writes = (fake: FakeAccess) =>
103
+ fake.seen.filter((s) => s.method !== 'GET').map((s) => s.method);
104
+
105
+ type Rows = Record<string, Record<string, Record<string, State.ResourceState>>>;
106
+
107
+ /**
108
+ * ★ THE REAL ENGINE, NOT A MODEL OF IT: `Plan.make` + `apply` over an in-memory state store, as
109
+ * mesh-node-harness.ts does. `rows` is that store, so a test can serialise exactly what Alchemy
110
+ * would persist.
111
+ */
112
+ export const engine = (fake: FakeAccess, extra: Layer.Layer<never, never, never> = Layer.empty) => {
113
+ const rows: Rows = {};
114
+ const state = Layer.succeed(State.State, State.InMemoryService(rows));
115
+ const providers = Layer.mergeAll(
116
+ SaasOidcApplicationProvider().pipe(Layer.provide(fakeProviderLayer(fake))),
117
+ extra,
118
+ );
119
+ const deploy = <A, E, R>(declare: Effect.Effect<A, E, R>) =>
120
+ Effect.runPromiseExit(
121
+ declare.pipe(
122
+ makeStack({ name: 'oidc', providers, state }) as never,
123
+ Effect.flatMap((compiled: CompiledStack) =>
124
+ Plan.make(compiled).pipe(Effect.flatMap(apply), Effect.provide(compiled.services)),
125
+ ),
126
+ Effect.provide(Layer.succeed(Stage, 'test')),
127
+ Effect.scoped,
128
+ Effect.provideService(FetchHttpClient.Fetch, fake.fetch),
129
+ ) as Effect.Effect<unknown, unknown>,
130
+ );
131
+ return {
132
+ deploy,
133
+ rows,
134
+ status: (fqn: string) => rows['oidc']?.['test']?.[fqn]?.status,
135
+ };
136
+ };
137
+
138
+ /** The failure sentence of a deploy, or `''` when it succeeded. */
139
+ export const failureOf = (exit: Exit.Exit<unknown, unknown>) =>
140
+ Exit.isFailure(exit) ? String(Cause.squash(exit.cause)) : '';
@@ -0,0 +1,150 @@
1
+ /**
2
+ * read / reconcile / delete for `HomeFlare.Access.SaasOidcApplication`, as plain Effects over the
3
+ * calls in saas-oidc-api.ts. saas-oidc.ts only wires them into Alchemy.
4
+ */
5
+ import { Unowned } from 'alchemy/AdoptPolicy';
6
+ import { createPhysicalName } from 'alchemy/PhysicalName';
7
+ import * as Effect from 'effect/Effect';
8
+ import { createApp, deleteApp, findSaasByName, getApp, updateApp } from './saas-oidc-api.ts';
9
+ import { SaasOidcError } from './saas-oidc-error.ts';
10
+ import {
11
+ type SaasOidcApplicationAttributes,
12
+ type SaasOidcApplicationProps,
13
+ checkTeam,
14
+ needsSync,
15
+ toAttributes,
16
+ validateSaasOidc,
17
+ writeBody,
18
+ } from './saas-oidc-form.ts';
19
+ import { checkTeamBeforeCreate } from './saas-oidc-team.ts';
20
+ import type { ObservedApp } from './saas-oidc-wire.ts';
21
+
22
+ const resolveName = (id: string, name: string | undefined) =>
23
+ name !== undefined && name !== '' ? Effect.succeed(name) : createPhysicalName({ id });
24
+
25
+ /**
26
+ * The live app: by the known id when there is one, else by exact name. A saas app found by id that
27
+ * is NOT type `saas` is refused, never rewritten: `applicationId` pointing at a self-hosted app
28
+ * would otherwise have its type overwritten by the update.
29
+ */
30
+ const observe = (
31
+ accountId: string,
32
+ knownId: string | undefined,
33
+ name: string,
34
+ options: { readonly byIdOnly?: boolean } = {},
35
+ ) =>
36
+ Effect.gen(function* () {
37
+ const byId = knownId === undefined ? undefined : yield* getApp(accountId, knownId);
38
+ if (byId !== undefined) {
39
+ if (byId.type !== 'saas') {
40
+ return yield* Effect.fail(
41
+ new SaasOidcError({
42
+ message: `Access application ${knownId} is type "${byId.type ?? 'unknown'}", not "saas". Refusing to rewrite it as a SaaS OIDC app.`,
43
+ }),
44
+ );
45
+ }
46
+ return byId;
47
+ }
48
+ // ★ An OWNED row's app that is gone stays gone: another saas app with the same name is not it,
49
+ // and its applicationId and clientId (declared stables) must not change under the row.
50
+ if (options.byIdOnly === true) return undefined;
51
+ const foundId = yield* findSaasByName(accountId, name);
52
+ return foundId === undefined ? undefined : yield* getApp(accountId, foundId);
53
+ });
54
+
55
+ /**
56
+ * Owned: refresh by the stored id ONLY (gone is `undefined`, never a same-named app). Cold: find the app by `applicationId` or exact name and hand it
57
+ * back `Unowned`, so Alchemy refuses to take it over until the stack says `adopt(true)`.
58
+ * ★ `olds` MAY PREDATE `teamDomain` (state written by the openbao copy of this resource): the host
59
+ * Cloudflare reports for the app stands in, so the first plan after the move can still read.
60
+ */
61
+ export const readSaasOidc = (
62
+ accountId: string,
63
+ id: string,
64
+ olds: SaasOidcApplicationProps | undefined,
65
+ output: SaasOidcApplicationAttributes | undefined,
66
+ ) =>
67
+ Effect.gen(function* () {
68
+ const name = yield* resolveName(id, output?.name ?? olds?.name);
69
+ const owned = output?.applicationId;
70
+ const observed = yield* observe(accountId, owned ?? olds?.applicationId, name, {
71
+ byIdOnly: owned !== undefined,
72
+ });
73
+ if (observed === undefined) return undefined;
74
+ const attributes = toAttributes(
75
+ observed,
76
+ output?.accountId ?? accountId,
77
+ name,
78
+ output?.teamDomain ?? olds?.teamDomain,
79
+ );
80
+ if (attributes === undefined) return undefined;
81
+ return owned !== undefined ? attributes : Unowned(attributes);
82
+ });
83
+
84
+ const missing = (what: string) =>
85
+ Effect.fail(
86
+ new SaasOidcError({
87
+ message: `SaaS OIDC ${what} succeeded but the GET did not return the app.`,
88
+ }),
89
+ );
90
+
91
+ /** observe, ensure (create or sync), read back. A no-op when nothing drifted. */
92
+ export const reconcileSaasOidc = (
93
+ accountId: string,
94
+ id: string,
95
+ news: SaasOidcApplicationProps,
96
+ output: SaasOidcApplicationAttributes | undefined,
97
+ ) =>
98
+ Effect.gen(function* () {
99
+ const invalid = validateSaasOidc(news);
100
+ if (invalid !== undefined) return yield* Effect.fail(invalid);
101
+ const name = yield* resolveName(id, news.name);
102
+ // ★ A stored applicationId means the row owns THAT app: if it is gone, create (or refuse),
103
+ // never adopt whatever app now carries the name. Only a cold run may look up by name.
104
+ const ownedId = output?.applicationId;
105
+ let observed: ObservedApp | undefined = yield* observe(
106
+ accountId,
107
+ ownedId ?? news.applicationId,
108
+ name,
109
+ { byIdOnly: ownedId !== undefined },
110
+ );
111
+ // ⛔ BEFORE ANY WRITE TO AN EXISTING APP: a wrong team domain must not cost it an update.
112
+ // A FIRST CREATE has no app to compare with, so it is checked against the account's Zero
113
+ // Trust organization instead (`checkTeamBeforeCreate`, below). ⚠️ That read needs a token
114
+ // permission the app write does not (saas-oidc-team.ts): where it cannot be read, a wrong
115
+ // `teamDomain` still writes the app and then fails the post-write check, leaving a live app
116
+ // with no state row. docs/saas-oidc.md says what to do.
117
+ const wrongTeam =
118
+ observed === undefined ? undefined : checkTeam(news.teamDomain, observed, output?.teamDomain);
119
+ if (wrongTeam !== undefined) return yield* Effect.fail(wrongTeam);
120
+ const write = writeBody(news, name);
121
+ if (observed === undefined) {
122
+ const wrongOrganization = yield* checkTeamBeforeCreate(accountId, news.teamDomain);
123
+ if (wrongOrganization !== undefined) return yield* Effect.fail(wrongOrganization);
124
+ const createdId = yield* createApp(accountId, write);
125
+ observed = yield* getApp(accountId, createdId);
126
+ if (observed === undefined) return yield* missing('create');
127
+ } else if (needsSync(news, observed, name)) {
128
+ const appId = observed.id;
129
+ if (appId === undefined) {
130
+ return yield* Effect.fail(new SaasOidcError({ message: 'SaaS OIDC app has no id.' }));
131
+ }
132
+ yield* updateApp(accountId, appId, write);
133
+ observed = yield* getApp(accountId, appId);
134
+ if (observed === undefined) return yield* missing('update');
135
+ }
136
+ const teamAfterWrite = checkTeam(news.teamDomain, observed, output?.teamDomain);
137
+ if (teamAfterWrite !== undefined) return yield* Effect.fail(teamAfterWrite);
138
+ const attributes = toAttributes(observed, accountId, name, news.teamDomain);
139
+ if (attributes === undefined) {
140
+ return yield* Effect.fail(
141
+ new SaasOidcError({
142
+ message: 'Cloudflare returned a SaaS OIDC application without an id, aud or client id.',
143
+ }),
144
+ );
145
+ }
146
+ return attributes;
147
+ });
148
+
149
+ export const deleteSaasOidc = (output: SaasOidcApplicationAttributes) =>
150
+ deleteApp(output.accountId, output.applicationId);
@@ -0,0 +1,180 @@
1
+ /**
2
+ * A response the SDK cannot validate must not carry the client secret out of this package.
3
+ *
4
+ * ⛔ THE LEAK THIS PINS. Under STRICT response validation, `@distilled.cloud/cloudflare@1.0.0-rc.13`
5
+ * fails a 2xx response it cannot decode with `CloudflareParseError({ body, cause })`, `body` being
6
+ * the WHOLE parsed response (protocol.ts:452-457). For a confidential client a create response
7
+ * carries `client_secret`, so a raw SDK error that reached a log or a trace would print it.
8
+ * `fake-access.ts` plants `FAKE_CLIENT_SECRET` in a body the decoder rejects, for each call.
9
+ * ★ THE FIRST TEST IS THE PREMISE: the raw SDK error really does carry the secret under the strict
10
+ * switch `strict` provides, so a renamed key or a lenient default fails there, not silently below.
11
+ */
12
+ import { describe, expect, spyOn, test } from 'bun:test';
13
+ import { CloudflareParseError } from '@distilled.cloud/cloudflare/Errors';
14
+ import * as zeroTrust from '@distilled.cloud/cloudflare/zero-trust';
15
+ import * as Cause from 'effect/Cause';
16
+ import * as Effect from 'effect/Effect';
17
+ import * as Exit from 'effect/Exit';
18
+ import { FAKE_CLIENT_SECRET, fakeAccess } from './fake-access.ts';
19
+ import { FAKE_ACCOUNT, fakeClientLayer } from './fake-mesh.ts';
20
+ import { deleteSaasOidc } from './saas-oidc-lifecycle.ts';
21
+ import { sdkWrites } from './saas-oidc-api.ts';
22
+ import { LIVE_OIDC, publicClient, reconcile, run, strict } from './saas-oidc-harness.ts';
23
+
24
+ /** Run `go` with the console watched; the second member is everything it logged. */
25
+ const watched = async <T>(go: () => Promise<T>): Promise<readonly [T, string]> => {
26
+ const spies = [spyOn(console, 'log'), spyOn(console, 'error'), spyOn(console, 'warn')];
27
+ try {
28
+ const result = await go();
29
+ return [result, JSON.stringify(spies.map((spy) => spy.mock.calls))];
30
+ } finally {
31
+ for (const spy of spies) spy.mockRestore();
32
+ }
33
+ };
34
+
35
+ /** Everything a caller or a log could render from a failure: nothing may hold the secret. */
36
+ const surfaces = (exit: Exit.Exit<unknown, unknown>, logged: string): string[] => {
37
+ if (!Exit.isFailure(exit)) throw new Error('expected a failure');
38
+ const error = Cause.squash(exit.cause);
39
+ const fields = (error instanceof Error ? Object.getOwnPropertyNames(error) : []).map(
40
+ (key) => (error as unknown as Record<string, unknown>)[key],
41
+ );
42
+ return [
43
+ String(error),
44
+ JSON.stringify(error),
45
+ JSON.stringify(fields),
46
+ error instanceof Error ? error.message : '',
47
+ Cause.pretty(exit.cause),
48
+ logged,
49
+ ];
50
+ };
51
+
52
+ const failureOf = (exit: Exit.Exit<unknown, unknown>): { _tag: string; message: string } => {
53
+ if (!Exit.isFailure(exit)) throw new Error('expected a failure');
54
+ return Cause.squash(exit.cause) as { _tag: string; message: string };
55
+ };
56
+
57
+ type Outcome = Promise<Exit.Exit<unknown, unknown>>;
58
+
59
+ /**
60
+ * The error rc.13 builds (protocol.ts:452-457) for a create that answered with a secret: the whole
61
+ * envelope as `body`, a SchemaError-like `cause` quoting the offending value.
62
+ */
63
+ const rawParseError = () =>
64
+ new CloudflareParseError({
65
+ body: { success: true, result: { id: 'app', saas_app: { client_secret: FAKE_CLIENT_SECRET } } },
66
+ cause: `Expected string, actual ${FAKE_CLIENT_SECRET}`,
67
+ });
68
+
69
+ /** Stand in for one SDK write that fails with the raw parse error, then put the real ones back. */
70
+ const withRawWrite = async (which: 'create' | 'update', go: () => Outcome): Outcome => {
71
+ const real = { ...sdkWrites };
72
+ Object.assign(sdkWrites, { [which]: () => Effect.fail(rawParseError()) });
73
+ try {
74
+ return await go();
75
+ } finally {
76
+ Object.assign(sdkWrites, real);
77
+ }
78
+ };
79
+
80
+ /**
81
+ * One way to make each call fail with CloudflareParseError, and what the sanitized error names.
82
+ * ⚠️ create and update go through `sdkWrites` because rc.13 cannot be made to fail them (their
83
+ * response decodes against `S.Unknown`, see saas-oidc-error.ts); the other three are the REAL SDK
84
+ * under strict validation, fed a body its typed schema rejects.
85
+ */
86
+ const scenarios: ReadonlyArray<readonly [string, string, () => Outcome]> = [
87
+ [
88
+ 'create',
89
+ 'application create',
90
+ () =>
91
+ withRawWrite('create', () =>
92
+ run(fakeAccess(), (p) => Effect.exit(reconcile(p, publicClient()))),
93
+ ),
94
+ ],
95
+ [
96
+ 'update',
97
+ 'application update',
98
+ () => {
99
+ const fake = fakeAccess();
100
+ const live = fake.seed({ name: 'Headlamp', policies: ['policy-old'], saas_app: LIVE_OIDC });
101
+ return withRawWrite('update', () =>
102
+ run(fake, (p) => Effect.exit(reconcile(p, publicClient({ applicationId: live.id })))),
103
+ );
104
+ },
105
+ ],
106
+ [
107
+ 'list',
108
+ 'application list',
109
+ () =>
110
+ run(fakeAccess({ malformed: ['list'] }), (p) =>
111
+ Effect.exit(strict(reconcile(p, publicClient()))),
112
+ ),
113
+ ],
114
+ [
115
+ 'org',
116
+ 'organization read',
117
+ () =>
118
+ run(fakeAccess({ malformed: ['org'] }), (p) =>
119
+ Effect.exit(strict(reconcile(p, publicClient()))),
120
+ ),
121
+ ],
122
+ [
123
+ 'delete',
124
+ 'application delete',
125
+ async () => {
126
+ const fake = fakeAccess({ malformed: ['delete'] });
127
+ const made = await run(fake, (p) => reconcile(p, publicClient()));
128
+ return Effect.runPromiseExit(
129
+ strict(deleteSaasOidc(made)).pipe(Effect.provide(fakeClientLayer(fake))),
130
+ );
131
+ },
132
+ ],
133
+ ];
134
+
135
+ describe('a response the SDK cannot validate', () => {
136
+ test('premise: the real SDK raises CloudflareParseError carrying the whole response', async () => {
137
+ const fake = fakeAccess({ malformed: ['delete'] });
138
+ const made = await run(fake, (p) => reconcile(p, publicClient()));
139
+ const raw = await Effect.runPromise(
140
+ strict(
141
+ zeroTrust.deleteAccessApplicationForAccount({
142
+ accountId: FAKE_ACCOUNT,
143
+ appId: made.applicationId,
144
+ }),
145
+ ).pipe(Effect.flip, Effect.provide(fakeClientLayer(fake))),
146
+ );
147
+ expect(raw).toMatchObject({
148
+ _tag: 'CloudflareParseError',
149
+ body: { result: { client_secret: FAKE_CLIENT_SECRET } },
150
+ });
151
+ });
152
+
153
+ test.each(scenarios)(
154
+ '%s: only the operation is named, never a body or a field',
155
+ async (_op, name, go) => {
156
+ const [exit, logged] = await watched(go);
157
+ const error = failureOf(exit);
158
+ expect(error._tag).toBe('SaasOidcError');
159
+ expect(error.message).toContain(name);
160
+ expect(error.message).toContain('withheld');
161
+ for (const surface of surfaces(exit, logged)) {
162
+ expect(surface).not.toContain(FAKE_CLIENT_SECRET);
163
+ expect(surface).not.toContain('client_secret');
164
+ }
165
+ },
166
+ );
167
+
168
+ test('the seam leaves the real SDK in place afterwards', () => {
169
+ expect(sdkWrites.create).toBe(zeroTrust.createAccessApplicationForAccount);
170
+ expect(sdkWrites.update).toBe(zeroTrust.updateAccessApplicationForAccount);
171
+ });
172
+
173
+ test('the raw read (not an SDK call) fails on a non-JSON body without echoing it', async () => {
174
+ const [exit, logged] = await watched(() =>
175
+ run(fakeAccess({ malformed: ['get'] }), (p) => Effect.exit(reconcile(p, publicClient()))),
176
+ );
177
+ expect(failureOf(exit).message).toContain('was not JSON');
178
+ for (const surface of surfaces(exit, logged)) expect(surface).not.toContain(FAKE_CLIENT_SECRET);
179
+ });
180
+ });