@homeflare/alchemy 0.29.1 → 0.31.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 (118) hide show
  1. package/README.md +42 -0
  2. package/dist/caddy/config-reconcile.d.ts.map +1 -1
  3. package/dist/caddy/config.d.ts.map +1 -1
  4. package/dist/caddy/format-warnings.d.ts +29 -0
  5. package/dist/caddy/format-warnings.d.ts.map +1 -0
  6. package/dist/caddy/format.d.ts +50 -0
  7. package/dist/caddy/format.d.ts.map +1 -0
  8. package/dist/caddy/index.d.ts +1 -0
  9. package/dist/caddy/index.d.ts.map +1 -1
  10. package/dist/caddy/index.js +78 -10
  11. package/dist/caddy/index.js.map +7 -5
  12. package/dist/discord/application-command.d.ts +52 -0
  13. package/dist/discord/application-command.d.ts.map +1 -0
  14. package/dist/discord/command-form.d.ts +62 -0
  15. package/dist/discord/command-form.d.ts.map +1 -0
  16. package/dist/discord/credentials.d.ts +35 -0
  17. package/dist/discord/credentials.d.ts.map +1 -0
  18. package/dist/discord/fake-discord.d.ts +44 -0
  19. package/dist/discord/fake-discord.d.ts.map +1 -0
  20. package/dist/discord/guild-application-command.d.ts +53 -0
  21. package/dist/discord/guild-application-command.d.ts.map +1 -0
  22. package/dist/discord/index.d.ts +12 -0
  23. package/dist/discord/index.d.ts.map +1 -0
  24. package/dist/discord/index.js +193 -0
  25. package/dist/discord/index.js.map +15 -0
  26. package/dist/discord/providers.d.ts +15 -0
  27. package/dist/discord/providers.d.ts.map +1 -0
  28. package/dist/discord/resource.d.ts +75 -0
  29. package/dist/discord/resource.d.ts.map +1 -0
  30. package/dist/google-workspace/credentials.d.ts +54 -0
  31. package/dist/google-workspace/credentials.d.ts.map +1 -0
  32. package/dist/google-workspace/domain-alias.d.ts +74 -0
  33. package/dist/google-workspace/domain-alias.d.ts.map +1 -0
  34. package/dist/google-workspace/fake-google-workspace.d.ts +41 -0
  35. package/dist/google-workspace/fake-google-workspace.d.ts.map +1 -0
  36. package/dist/google-workspace/group-member.d.ts +73 -0
  37. package/dist/google-workspace/group-member.d.ts.map +1 -0
  38. package/dist/google-workspace/group.d.ts +87 -0
  39. package/dist/google-workspace/group.d.ts.map +1 -0
  40. package/dist/google-workspace/index.d.ts +19 -0
  41. package/dist/google-workspace/index.d.ts.map +1 -0
  42. package/dist/google-workspace/index.js +283 -0
  43. package/dist/google-workspace/index.js.map +17 -0
  44. package/dist/google-workspace/org-unit.d.ts +75 -0
  45. package/dist/google-workspace/org-unit.d.ts.map +1 -0
  46. package/dist/google-workspace/providers.d.ts +16 -0
  47. package/dist/google-workspace/providers.d.ts.map +1 -0
  48. package/dist/google-workspace/resource.d.ts +80 -0
  49. package/dist/google-workspace/resource.d.ts.map +1 -0
  50. package/dist/google-workspace/values.d.ts +28 -0
  51. package/dist/google-workspace/values.d.ts.map +1 -0
  52. package/dist/grafana/credentials.d.ts +38 -0
  53. package/dist/grafana/credentials.d.ts.map +1 -0
  54. package/dist/grafana/datasource.d.ts +90 -0
  55. package/dist/grafana/datasource.d.ts.map +1 -0
  56. package/dist/grafana/fake-grafana.d.ts +48 -0
  57. package/dist/grafana/fake-grafana.d.ts.map +1 -0
  58. package/dist/grafana/index.d.ts +17 -0
  59. package/dist/grafana/index.d.ts.map +1 -0
  60. package/dist/grafana/index.js +149 -0
  61. package/dist/grafana/index.js.map +13 -0
  62. package/dist/grafana/providers.d.ts +17 -0
  63. package/dist/grafana/providers.d.ts.map +1 -0
  64. package/dist/grafana/resource.d.ts +68 -0
  65. package/dist/grafana/resource.d.ts.map +1 -0
  66. package/docs/caddy-fmt.md +59 -0
  67. package/docs/caddy.md +7 -0
  68. package/docs/credentials.md +20 -0
  69. package/docs/discord.md +129 -0
  70. package/docs/google-workspace.md +105 -0
  71. package/docs/grafana.md +102 -0
  72. package/docs/peers.md +9 -0
  73. package/docs/upstream-conformance.md +53 -0
  74. package/package.json +13 -1
  75. package/src/caddy/config-format-warning.test.ts +91 -0
  76. package/src/caddy/config-reconcile.ts +6 -0
  77. package/src/caddy/config.ts +8 -1
  78. package/src/caddy/format-warnings.test.ts +50 -0
  79. package/src/caddy/format-warnings.ts +39 -0
  80. package/src/caddy/format.test.ts +105 -0
  81. package/src/caddy/format.ts +121 -0
  82. package/src/caddy/index.ts +7 -0
  83. package/src/caddy/providers.test.ts +8 -0
  84. package/src/discord/application-command.test.ts +103 -0
  85. package/src/discord/application-command.ts +109 -0
  86. package/src/discord/command-form.test.ts +144 -0
  87. package/src/discord/command-form.ts +133 -0
  88. package/src/discord/credentials.ts +36 -0
  89. package/src/discord/fake-discord.ts +70 -0
  90. package/src/discord/guild-application-command.test.ts +215 -0
  91. package/src/discord/guild-application-command.ts +129 -0
  92. package/src/discord/index.ts +31 -0
  93. package/src/discord/providers.ts +18 -0
  94. package/src/discord/resource.ts +164 -0
  95. package/src/discord/retry.test.ts +74 -0
  96. package/src/google-workspace/credentials.test.ts +40 -0
  97. package/src/google-workspace/credentials.ts +75 -0
  98. package/src/google-workspace/domain-alias.test.ts +69 -0
  99. package/src/google-workspace/domain-alias.ts +116 -0
  100. package/src/google-workspace/fake-google-workspace.ts +54 -0
  101. package/src/google-workspace/group-member.test.ts +91 -0
  102. package/src/google-workspace/group-member.ts +126 -0
  103. package/src/google-workspace/group.test.ts +93 -0
  104. package/src/google-workspace/group.ts +128 -0
  105. package/src/google-workspace/index.ts +54 -0
  106. package/src/google-workspace/org-unit.test.ts +81 -0
  107. package/src/google-workspace/org-unit.ts +143 -0
  108. package/src/google-workspace/providers.ts +26 -0
  109. package/src/google-workspace/resource.test.ts +186 -0
  110. package/src/google-workspace/resource.ts +162 -0
  111. package/src/google-workspace/values.ts +36 -0
  112. package/src/grafana/credentials.ts +65 -0
  113. package/src/grafana/datasource.test.ts +183 -0
  114. package/src/grafana/datasource.ts +171 -0
  115. package/src/grafana/fake-grafana.ts +64 -0
  116. package/src/grafana/index.ts +22 -0
  117. package/src/grafana/providers.ts +19 -0
  118. package/src/grafana/resource.ts +120 -0
@@ -0,0 +1,164 @@
1
+ /**
2
+ * One shape for every Discord application-command object — global and guild-scoped alike.
3
+ *
4
+ * ⛔ THE ALTERNATIVE IS COPYING THE SAME READ/RECONCILE/DELETE PER RESOURCE. `application-command.ts`
5
+ * and `guild-application-command.ts` differ only in which distilled operations they call and
6
+ * whether `guild_id` is part of identity — so the read/diff/reconcile/delete flow lives here
7
+ * once, mirroring `../netbox/resource.ts`'s `netboxOperations`/`netboxHandlers` split.
8
+ *
9
+ * ★ UNLIKE NETBOX, THIS FOLLOWS UPSTREAM'S `Snippet.ts` READ/RECONCILE SHAPE EXACTLY (S7–S9), NOT
10
+ * netbox's "adopt silently" gap (H1). Discord commands carry no ownership marker of any kind —
11
+ * no tags, no metadata field a bot could stamp — so this is precisely the "marker-less API" S8
12
+ * describes, and `Snippet.ts@v2.0.0-beta.79#read` is the cited reference: an existing match is
13
+ * `Unowned(attrs)` on a cold read (no persisted `output`), and plain attributes on a warm read
14
+ * (persisted `output` says we already made or adopted this exact command). `adopt(true)` (H5),
15
+ * piped onto each resource by the constructors in the two resource files, is what turns that
16
+ * `Unowned` into a silent one-time takeover instead of an `OwnedBySomeoneElse` refusal.
17
+ *
18
+ * ★ RECONCILE IS ONE UPSERT CALL, LIKE `Snippet.ts`'s `putSnippet`. Discord's own docs for both
19
+ * Create Global/Guild Application Command say a command with the same name (and type) as an
20
+ * existing one OVERWRITES it — the create endpoint is a true upsert, not a create-only call that
21
+ * 409s on a name collision. ⚠️ REASONED NOT MEASURED (no token available to this task; see
22
+ * `docs/discord.md`): this is Discord's documented behaviour, unchanged across API v10, but it
23
+ * has not been exercised against the real API by this change. One call therefore converges
24
+ * greenfield create, routine update AND adoption alike — there is no separate PATCH path, and
25
+ * S10's "zero write calls when there is no drift" is still honoured because `reconcile` skips
26
+ * the call entirely when `spec.matches` already holds.
27
+ *
28
+ * ⚠️ `list` ANSWERS EMPTY, ON PURPOSE, EVEN THOUGH THE DISTILLED LIST OPERATIONS EXIST. A bot's
29
+ * guild command set is exactly what `hf-discord-halibut.service` bulk-overwrites at every
30
+ * restart (see `docs/discord.md#halibut`); a `nuke` that enumerated and swept every live command
31
+ * would fight that ownership, not just this resource's own declarations. Adoption stays
32
+ * explicit, the same reasoning netbox's `list` gives for a shared instance predating the kit.
33
+ */
34
+ import { Unowned } from 'alchemy/AdoptPolicy';
35
+ import { isResolved } from 'alchemy/Diff';
36
+ import type { Input } from 'alchemy/Input';
37
+ import type { DiscordOpContext } from '@distilled.cloud/discord/Protocol';
38
+ import * as Effect from 'effect/Effect';
39
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
40
+ import { CredentialsFromEnv } from './credentials.ts';
41
+
42
+ /**
43
+ * What every stack must still provide after `discordHandlers` bakes in `CredentialsFromEnv`
44
+ * (below): the transport. `DiscordOpContext` is `Credentials | HttpClient.HttpClient`, so once
45
+ * `Credentials` is supplied, `HttpClient.HttpClient` is what remains — mirroring
46
+ * `../netbox/resource.ts#NetboxRequirements`.
47
+ */
48
+ export type DiscordRequirements = HttpClient.HttpClient;
49
+
50
+ /** Every Discord command resource's identity key, regardless of scope. */
51
+ export interface DiscordCommandIdentity {
52
+ readonly name: string;
53
+ }
54
+
55
+ /**
56
+ * One Discord command object's distilled calls. `Live` is whatever the SDK decodes
57
+ * (`ApplicationCommandResponse`); `E` is left to each resource file to declare, so a call site's
58
+ * precise error union (in practice always `DiscordOpError` — see `protocol.ts` — because every
59
+ * generated Discord operation shares one error channel) flows straight through.
60
+ */
61
+ export type DiscordSpec<
62
+ Props extends DiscordCommandIdentity,
63
+ Live,
64
+ Attributes extends object,
65
+ E,
66
+ > = {
67
+ /** Locate the live command by its declared name within this spec's scope (app, or app+guild). */
68
+ readonly fetchByName: (props: Props) => Effect.Effect<Live | undefined, E, DiscordOpContext>;
69
+ readonly attributes: (live: Live) => Attributes;
70
+ readonly matches: (attributes: Attributes, props: Props) => boolean;
71
+ readonly upsertBody: (props: Props) => Record<string, unknown>;
72
+ /** POST create-or-overwrite — see the upsert note above. */
73
+ readonly upsert: (
74
+ props: Props,
75
+ body: Record<string, unknown>,
76
+ ) => Effect.Effect<Live, E, DiscordOpContext>;
77
+ readonly destroy: (props: Props, live: Live) => Effect.Effect<unknown, E, DiscordOpContext>;
78
+ /** For error messages and test descriptions. */
79
+ readonly describe: (props: Props) => string;
80
+ };
81
+
82
+ export const discordOperations = <
83
+ Props extends DiscordCommandIdentity,
84
+ Live,
85
+ Attributes extends object,
86
+ E,
87
+ >(
88
+ spec: DiscordSpec<Props, Live, Attributes, E>,
89
+ ) => ({
90
+ read: ({ olds, output }: { olds: Props; output: Attributes | undefined }) =>
91
+ Effect.gen(function* () {
92
+ const live = yield* spec.fetchByName(olds);
93
+ if (live === undefined) return undefined;
94
+ const attrs = spec.attributes(live);
95
+ // Warm read: a prior apply already created or adopted this exact command — ours.
96
+ if (output !== undefined) return attrs;
97
+ // Cold read (fresh store, or state lost): no ownership marker exists on this vendor,
98
+ // so any match is `Unowned` until `adopt(true)` (or `--adopt`) says otherwise (S7, S8).
99
+ return Unowned(attrs);
100
+ }),
101
+
102
+ diff: (news: Input<Props>, output: Attributes | undefined) =>
103
+ Effect.gen(function* () {
104
+ if (output === undefined || !isResolved(news)) return undefined;
105
+ const live = yield* spec.fetchByName(news);
106
+ if (live === undefined) return { action: 'update' } as const;
107
+ return spec.matches(spec.attributes(live), news)
108
+ ? ({ action: 'noop' } as const)
109
+ : ({ action: 'update' } as const);
110
+ }),
111
+
112
+ reconcile: (news: Props) =>
113
+ Effect.gen(function* () {
114
+ const before = yield* spec.fetchByName(news);
115
+ if (before !== undefined && spec.matches(spec.attributes(before), news)) {
116
+ // Already exactly what is declared — S10: no write.
117
+ return spec.attributes(before);
118
+ }
119
+ const body = spec.upsertBody(news);
120
+ const after = yield* spec.upsert(news, body);
121
+ return spec.attributes(after);
122
+ }),
123
+
124
+ // ⚠️ GAP, NOT SOLVED: a TOCTOU race between the fetch above and the delete call below could
125
+ // see Discord answer 404 on a command that vanished between the two — S11 asks that race to
126
+ // be treated as success, `catchTag('NotFound', …)`. The SDK's declared error type for every
127
+ // operation does not include `NotFound` (see `docs/discord.md#sdk-gaps` — measured, not
128
+ // assumed), so there is no typed tag to catch here without an unsafe cast. Left unhandled.
129
+ destroy: (olds: Props) =>
130
+ Effect.gen(function* () {
131
+ const live = yield* spec.fetchByName(olds);
132
+ if (live === undefined) return;
133
+ yield* spec.destroy(olds, live);
134
+ }),
135
+ });
136
+
137
+ /**
138
+ * The four provider handlers for a spec'd Discord command, wired once.
139
+ *
140
+ * ★ CREDENTIALS ARE PROVIDED HERE, ONCE. `CredentialsFromEnv` resolves `DISCORD_BOT_TOKEN` /
141
+ * `DISCORD_TOKEN` on the calling fiber per request (credentials.ts), so this closure keeps that
142
+ * laziness — nothing is captured at module load.
143
+ */
144
+ export const discordHandlers = <
145
+ Props extends DiscordCommandIdentity,
146
+ Live,
147
+ Attributes extends object,
148
+ E,
149
+ >(
150
+ spec: DiscordSpec<Props, Live, Attributes, E>,
151
+ ) => {
152
+ const ops = discordOperations(spec);
153
+ const withCredentials = <A>(effect: Effect.Effect<A, E, DiscordOpContext>) =>
154
+ Effect.provide(effect, CredentialsFromEnv);
155
+ return {
156
+ list: () => Effect.succeed([]),
157
+ read: ({ olds, output }: { olds: Props; output: Attributes | undefined }) =>
158
+ withCredentials(ops.read({ olds, output })),
159
+ diff: ({ news, output }: { news: Input<Props>; output: Attributes | undefined }) =>
160
+ withCredentials(ops.diff(news, output)),
161
+ reconcile: ({ news }: { news: Props }) => withCredentials(ops.reconcile(news)),
162
+ delete: ({ olds }: { olds: Props }) => withCredentials(ops.destroy(olds)),
163
+ };
164
+ };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Discord's 429 handling — proving the SDK's OWN default retry policy (`@distilled.cloud/core`'s
3
+ * `makeDefault`, threaded through every operation via `retry: Retry.Retry` — see `resource.ts`'s
4
+ * header note and `docs/discord.md#rate-limits`), not a house re-implementation. This file exists
5
+ * because "respect Discord's rate limits with a bounded retry" is a requirement this task must
6
+ * satisfy, and the honest way to satisfy it here is to prove the SDK already does — nothing in
7
+ * `resource.ts` installs a `Discord.Retry.policy` of its own, so if this test ever failed to
8
+ * retry, or failed to STOP retrying, there would be nothing in this package to fix; the fix would
9
+ * be in `@distilled.cloud/discord`.
10
+ *
11
+ * ⛔ A TEST THAT CANNOT FAIL PROVES NOTHING. Both cases below assert on the fake's own call count
12
+ * (`fake.seen.length`), not just on the eventual Effect outcome — a policy that retried once,
13
+ * or one that retried forever, would each still "eventually succeed" or "eventually fail" on
14
+ * SOME outcome; the call count is what makes "bounded" a real, checkable claim rather than an
15
+ * assumption the test's own passing would not have caught.
16
+ */
17
+ import { describe, expect, test } from 'bun:test';
18
+ import * as Effect from 'effect/Effect';
19
+ import { spec } from './guild-application-command.ts';
20
+ import type { GuildApplicationCommandProps } from './guild-application-command.ts';
21
+ import { fakeDiscord, fakeDiscordLayer, fakeRateLimited } from './fake-discord.ts';
22
+
23
+ const APP = '111111111111111111';
24
+ const GUILD = '222222222222222222';
25
+ const CMD_PATH = `/applications/${APP}/guilds/${GUILD}/commands`;
26
+ const PROPS: GuildApplicationCommandProps = { applicationId: APP, guildId: GUILD, name: 'ping' };
27
+
28
+ const liveCommand = () => ({
29
+ application_id: APP,
30
+ default_member_permissions: null,
31
+ description: '',
32
+ guild_id: GUILD,
33
+ id: '999',
34
+ name: 'ping',
35
+ nsfw: false,
36
+ type: 1,
37
+ version: '1',
38
+ });
39
+
40
+ describe('the SDK default Retry policy on a 429', () => {
41
+ test('two 429s (Retry-After: 0) then a 200 — retried, and it converges', async () => {
42
+ const fake = fakeDiscord((method, url, _body, callNumber) => {
43
+ if (method !== 'GET' || url.pathname !== CMD_PATH) return new Response(null, { status: 500 });
44
+ return callNumber <= 2 ? fakeRateLimited(0) : Response.json([liveCommand()]);
45
+ });
46
+ const live = await Effect.runPromise(
47
+ spec.fetchByName(PROPS).pipe(Effect.provide(fakeDiscordLayer(fake.fetch))),
48
+ );
49
+ expect(live?.id).toBe('999');
50
+ expect(fake.seen).toHaveLength(3);
51
+ }, 15_000);
52
+
53
+ /**
54
+ * ★ THE BOUND ITSELF. `@distilled.cloud/core`'s `makeDefault` caps this at `Schedule.recurs(8)`
55
+ * — 8 retries, 9 attempts total — regardless of how long the server keeps answering 429. A
56
+ * server that never recovers must still let the caller find out, in bounded time, rather than
57
+ * parking the fiber forever; that is the literal meaning of "bounded retry".
58
+ */
59
+ test('429 forever is a bounded number of attempts, then a typed failure — never an infinite loop', async () => {
60
+ const fake = fakeDiscord((method, url) =>
61
+ method === 'GET' && url.pathname === CMD_PATH
62
+ ? fakeRateLimited(0)
63
+ : new Response(null, { status: 500 }),
64
+ );
65
+ const failure = await Effect.runPromise(
66
+ Effect.flip(spec.fetchByName(PROPS).pipe(Effect.provide(fakeDiscordLayer(fake.fetch)))),
67
+ );
68
+ expect(failure._tag).toBe('TooManyRequests');
69
+ // 1 initial attempt + at most 8 retries. Strictly greater than 1 proves a retry happened at
70
+ // all; at most 9 proves it stopped rather than continuing past the documented bound.
71
+ expect(fake.seen.length).toBeGreaterThan(1);
72
+ expect(fake.seen.length).toBeLessThanOrEqual(9);
73
+ }, 15_000);
74
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `describeKeyRef` — proves it formats a plain reference and refuses anything key-shaped, so the
3
+ * one place this family turns a `GoogleWorkspaceKeyRef` into text (docs, the census handoff
4
+ * header) cannot be handed a real credential by mistake.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { type GoogleWorkspaceKeyRef, describeKeyRef } from './credentials.ts';
8
+
9
+ const REF: GoogleWorkspaceKeyRef = {
10
+ delegatedAdmin: 'admin@schenanigans.com',
11
+ openBaoPath: 'kv/google-workspace/service-accounts/directory-admin',
12
+ scopes: ['https://www.googleapis.com/auth/admin.directory.group.readonly'],
13
+ };
14
+
15
+ describe('describeKeyRef', () => {
16
+ test('formats a plain reference — path, delegated admin, scopes', () => {
17
+ const line = describeKeyRef(REF);
18
+ expect(line).toContain('kv/google-workspace/service-accounts/directory-admin');
19
+ expect(line).toContain('admin@schenanigans.com');
20
+ expect(line).toContain('admin.directory.group.readonly');
21
+ });
22
+
23
+ test('refuses an openBaoPath that looks like a PEM private key', () => {
24
+ expect(() =>
25
+ describeKeyRef({ ...REF, openBaoPath: '-----BEGIN PRIVATE KEY-----\nMIIE...' }),
26
+ ).toThrow(/looks like key material/);
27
+ });
28
+
29
+ test('refuses a scopes entry that looks like a service-account JSON blob', () => {
30
+ expect(() =>
31
+ describeKeyRef({ ...REF, scopes: ['{"type": "service_account", "private_key": "x"}'] }),
32
+ ).toThrow(/looks like key material/);
33
+ });
34
+
35
+ test('refuses a delegatedAdmin field carrying a private_key fragment', () => {
36
+ expect(() => describeKeyRef({ ...REF, delegatedAdmin: 'private_key leaked here' })).toThrow(
37
+ /looks like key material/,
38
+ );
39
+ });
40
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Google Workspace credentials for the house — a lazy reference, never the key.
3
+ *
4
+ * ⛔ THE DISTILLED SDK'S OWN `Credentials` SERVICE TAKES ONE THING: an already-minted OAuth2
5
+ * bearer access token (`@distilled.cloud/google-workspace/Credentials` — read at
6
+ * `node_modules/@distilled.cloud/google-workspace/src/credentials.ts`, 2026-09-24: "API-compatible
7
+ * port of the distilled gcp credentials module … no ADC, no service-account signing, no refresh
8
+ * flow"). It has no service-account JSON shape and no domain-wide-delegation JWT exchange built
9
+ * in — minting the token is entirely outside the SDK's surface.
10
+ *
11
+ * ★ SO THE HOUSE PATTERN IS H8'S, UNCHANGED: a short-lived credential is minted from OpenBao and
12
+ * exported into the DEPLOY PROCESS's environment only, by a Bun wrapper the operator runs — the
13
+ * same shape the kit's Proxmox subpath already uses for its own OpenBao-sourced token. This
14
+ * family adds nothing that signs a JWT or talks to Google's token endpoint; that stays Tim's
15
+ * wrapper script, documented in the README, because building it here would be a second,
16
+ * hand-rolled auth flow next to the one the SDK already exposes (S23).
17
+ *
18
+ * ⛔ NEVER THE KEY. `GoogleWorkspaceKeyRef` below is a REFERENCE to where a service-account key
19
+ * lives — an OpenBao path, a delegated admin subject and the scopes the wrapper requests — not
20
+ * the key itself. Nothing in this family reads, parses or holds private key material; `describeKeyRef`
21
+ * is pure string formatting for docs and the census handoff header, and it is tested (below) to
22
+ * never accept or echo anything shaped like a key.
23
+ *
24
+ * Re-exported so a consumer imports one module for both the distilled service and the house
25
+ * reference type: `import { CredentialsFromEnv, fromAccessToken, type GoogleWorkspaceKeyRef } from
26
+ * '@homeflare/alchemy/google-workspace'`.
27
+ */
28
+ export {
29
+ type Config as GoogleWorkspaceConfig,
30
+ Credentials,
31
+ CredentialsFromEnv,
32
+ fromAccessToken,
33
+ } from '@distilled.cloud/google-workspace/Credentials';
34
+
35
+ /** Env var the SDK's `CredentialsFromEnv` reads — the wrapper mints into THIS var, nothing else. */
36
+ export const GOOGLE_ACCESS_TOKEN_ENV = 'GOOGLE_ACCESS_TOKEN';
37
+ /** Optional — only meaningful if a resource ever needs to scope a call to a GCP project. */
38
+ export const GOOGLE_PROJECT_ID_ENV = 'GOOGLE_PROJECT_ID';
39
+
40
+ /**
41
+ * Where the service-account key that a wrapper exchanges for `GOOGLE_ACCESS_TOKEN` lives —
42
+ * documentation and typing only. A stack file may keep one of these next to its declarations so
43
+ * the OpenBao path, delegated admin and scopes are reviewable in the same diff as the resources
44
+ * they authorize — nothing here reads OpenBao or Google, and no field may hold a key, a token or
45
+ * a private key fragment.
46
+ */
47
+ export interface GoogleWorkspaceKeyRef {
48
+ /** OpenBao KV path, e.g. `kv/google-workspace/service-accounts/directory-admin`. */
49
+ readonly openBaoPath: string;
50
+ /** The Workspace admin the service account impersonates via domain-wide delegation. */
51
+ readonly delegatedAdmin: string;
52
+ /** OAuth scopes the wrapper requests — keep this the least the declared resources need. */
53
+ readonly scopes: readonly string[];
54
+ }
55
+
56
+ /** Substrings that must never appear in a `GoogleWorkspaceKeyRef` field — this is a reference. */
57
+ const KEY_SHAPED = /-----BEGIN|private_key|"type":\s*"service_account"/i;
58
+
59
+ /**
60
+ * A one-line, key-free description for docs and the census handoff header. Throws rather than
61
+ * formatting a ref whose fields look like they hold key material — a `GoogleWorkspaceKeyRef` is
62
+ * a pointer, and this is the one place that assumption is checked instead of assumed.
63
+ */
64
+ export const describeKeyRef = (ref: GoogleWorkspaceKeyRef): string => {
65
+ for (const [field, value] of Object.entries(ref)) {
66
+ const text = Array.isArray(value) ? value.join(',') : String(value);
67
+ if (KEY_SHAPED.test(text)) {
68
+ throw new Error(`GoogleWorkspaceKeyRef.${field} looks like key material, not a reference`);
69
+ }
70
+ }
71
+ return (
72
+ `service account at OpenBao:${ref.openBaoPath}, delegating as ${ref.delegatedAdmin}, ` +
73
+ `scopes: ${ref.scopes.join(' ')}`
74
+ );
75
+ };
@@ -0,0 +1,69 @@
1
+ /**
2
+ * `GoogleWorkspace.DomainAlias`'s `spec` against a fake Directory API — no `update` operation
3
+ * exists (domain-alias.ts's own note), so this only proves `fetchLive`/`attributes`/`matches`.
4
+ */
5
+ import { describe, expect, test } from 'bun:test';
6
+ import * as Effect from 'effect/Effect';
7
+ import {
8
+ fakeFailure,
9
+ fakeGoogleWorkspace,
10
+ fakeGoogleWorkspaceLayer,
11
+ } from './fake-google-workspace.ts';
12
+ import { spec } from './domain-alias.ts';
13
+
14
+ const ALIAS_PATH = '/admin/directory/v1/customer/my_customer/domainaliases/alias.schenanigans.com';
15
+ const props = { domainAliasName: 'alias.schenanigans.com', parentDomainName: 'schenanigans.com' };
16
+
17
+ const readThrough = (fetchFn: typeof globalThis.fetch) =>
18
+ Effect.runPromise(
19
+ spec.fetchLive(props).pipe(
20
+ Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))),
21
+ Effect.provide(fakeGoogleWorkspaceLayer(fetchFn)),
22
+ ),
23
+ );
24
+
25
+ describe('GoogleWorkspace.DomainAlias spec.fetchLive + spec.attributes', () => {
26
+ test('a live alias decodes with the default my_customer scope', async () => {
27
+ const fake = fakeGoogleWorkspace((method, url) =>
28
+ method === 'GET' && url.pathname === ALIAS_PATH
29
+ ? Response.json({
30
+ creationTime: '1700000000000',
31
+ domainAliasName: 'alias.schenanigans.com',
32
+ parentDomainName: 'schenanigans.com',
33
+ verified: true,
34
+ })
35
+ : fakeFailure(404, 'not found', 'NOT_FOUND'),
36
+ );
37
+ expect(await readThrough(fake.fetch)).toEqual({
38
+ creationTime: '1700000000000',
39
+ customer: 'my_customer',
40
+ domainAliasName: 'alias.schenanigans.com',
41
+ parentDomainName: 'schenanigans.com',
42
+ verified: true,
43
+ });
44
+ });
45
+
46
+ test('a 404 folds to undefined', async () => {
47
+ const fake = fakeGoogleWorkspace(() => fakeFailure(404, 'not found', 'NOT_FOUND'));
48
+ expect(await readThrough(fake.fetch)).toBeUndefined();
49
+ });
50
+ });
51
+
52
+ describe('GoogleWorkspace.DomainAlias spec.matches and spec.update', () => {
53
+ const live = {
54
+ creationTime: '1700000000000',
55
+ customer: 'my_customer',
56
+ domainAliasName: 'alias.schenanigans.com',
57
+ parentDomainName: 'schenanigans.com',
58
+ verified: true,
59
+ };
60
+
61
+ test('the same parentDomainName is no drift', () => {
62
+ expect(spec.matches(live, props)).toBe(true);
63
+ });
64
+
65
+ test('a different parentDomainName is drift — and since there is no update, diff replaces', () => {
66
+ expect(spec.matches(live, { ...props, parentDomainName: 'homeflare.dev' })).toBe(false);
67
+ expect(spec.update).toBeUndefined();
68
+ });
69
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * `GoogleWorkspace.DomainAlias` — one secondary-domain alias on the Workspace account
3
+ * (`admin/directory/v1/customer/{customer}/domainaliases`).
4
+ *
5
+ * ⛔ NO `update` OPERATION EXISTS. The Directory API ships `get`/`insert`/`delete`/`list` for
6
+ * domain aliases and nothing else — measured against `admin_directory_v1.ts`: there is no
7
+ * `patchDomainAliases` or `updateDomainAliases`. `spec.update` is intentionally absent, so
8
+ * `resource.ts`'s `diff` reports `replace` for any drift rather than an unsendable PATCH — the
9
+ * same fallback NetBox's `netboxOperations` and Forgejo's `forgejoOperations` give a spec with
10
+ * no `update`.
11
+ *
12
+ * ★ `domainAliasName` IS THE WHOLE IDENTITY. `parentDomainName` only matters at create time —
13
+ * Google refuses reparenting an alias in place, so a declared `parentDomainName` that no longer
14
+ * matches the live row is exactly the identity change `replace` exists for.
15
+ *
16
+ * ⚠️ THE HOUSE'S CLOUDFLARE ZONES (`homeflare.dev`, `aimto.app`, `schenanigans.com`, `taslabs.net`
17
+ * — `site/live.site.json` in homeflare-landscape) are DNS zones, not Workspace domains by
18
+ * themselves; a zone only becomes a valid `parentDomainName` once it is added and verified as a
19
+ * primary or secondary domain in the Workspace admin console. This family does not verify a
20
+ * domain — Google's verification is a TXT/CNAME challenge outside the Directory API's write
21
+ * surface — so `create` will fail with `BadRequest` against an unverified domain, which is
22
+ * correct: nothing here should silently paper over a step the account owner has to do by hand.
23
+ */
24
+ import { Resource } from 'alchemy';
25
+ import * as Provider from 'alchemy/Provider';
26
+ import * as directory from '@distilled.cloud/google-workspace/unstable/admin_directory_v1';
27
+ import * as Effect from 'effect/Effect';
28
+ import type { GoogleWorkspaceRequirements, GoogleWorkspaceSpec } from './resource.ts';
29
+ import { googleWorkspaceHandlers } from './resource.ts';
30
+ import { bool, ciEqual, text } from './values.ts';
31
+
32
+ export interface DomainAliasProps {
33
+ /** The unique id for the Workspace account, or `my_customer` for the caller's own account. */
34
+ customer?: string;
35
+ /** The alias domain name — primary key for locate. */
36
+ domainAliasName: string;
37
+ /** The primary or secondary domain this alias resolves to. Immutable — a change replaces. */
38
+ parentDomainName: string;
39
+ }
40
+
41
+ export interface DomainAliasAttributes {
42
+ customer: string;
43
+ domainAliasName: string;
44
+ parentDomainName: string;
45
+ /** Read-only. */
46
+ verified: boolean;
47
+ /** Read-only, ISO-ish epoch-ms string as Google returns it. */
48
+ creationTime: string;
49
+ }
50
+
51
+ export interface GoogleWorkspaceDomainAlias extends Resource<
52
+ 'GoogleWorkspace.DomainAlias',
53
+ DomainAliasProps,
54
+ DomainAliasAttributes,
55
+ never,
56
+ GoogleWorkspaceRequirements
57
+ > {}
58
+
59
+ export const GoogleWorkspaceDomainAlias = Resource<GoogleWorkspaceDomainAlias>(
60
+ 'GoogleWorkspace.DomainAlias',
61
+ { defaultRemovalPolicy: 'retain' },
62
+ );
63
+
64
+ const customerOf = (props: DomainAliasProps): string => props.customer ?? 'my_customer';
65
+
66
+ const attributesOf = (
67
+ live: directory.DomainAlias,
68
+ props: DomainAliasProps,
69
+ ): DomainAliasAttributes | undefined => {
70
+ if (live.domainAliasName === undefined || !ciEqual(live.domainAliasName, props.domainAliasName)) {
71
+ return undefined;
72
+ }
73
+ return {
74
+ creationTime: text(live.creationTime),
75
+ customer: customerOf(props),
76
+ domainAliasName: live.domainAliasName,
77
+ parentDomainName: text(live.parentDomainName),
78
+ verified: bool(live.verified),
79
+ };
80
+ };
81
+
82
+ /** ★ EXPORTED for direct testing — see domain-alias.test.ts. */
83
+ export const spec: GoogleWorkspaceSpec<
84
+ DomainAliasProps,
85
+ directory.DomainAlias,
86
+ DomainAliasAttributes,
87
+ | directory.GetDomainAliasesError
88
+ | directory.InsertDomainAliasesError
89
+ | directory.DeleteDomainAliasesError
90
+ > = {
91
+ attributes: attributesOf,
92
+ create: (props) =>
93
+ directory.insertDomainAliases({
94
+ body: { domainAliasName: props.domainAliasName, parentDomainName: props.parentDomainName },
95
+ customer: customerOf(props),
96
+ }),
97
+ describe: (props) => `customer/${customerOf(props)}/domainaliases/${props.domainAliasName}`,
98
+ destroy: (props, live) =>
99
+ directory.deleteDomainAliases({
100
+ customer: customerOf(props),
101
+ domainAliasName: text(live.domainAliasName) || props.domainAliasName,
102
+ }),
103
+ fetchLive: (props) =>
104
+ directory
105
+ .getDomainAliases({ customer: customerOf(props), domainAliasName: props.domainAliasName })
106
+ .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
107
+ matches: (attributes, props) => attributes.parentDomainName === props.parentDomainName,
108
+ };
109
+
110
+ export const handlers = googleWorkspaceHandlers(spec);
111
+
112
+ export const GoogleWorkspaceDomainAliasProvider = () =>
113
+ Provider.effect(
114
+ GoogleWorkspaceDomainAlias,
115
+ Effect.succeed(GoogleWorkspaceDomainAlias.Provider.of(handlers)),
116
+ );
@@ -0,0 +1,54 @@
1
+ /**
2
+ * A minimal in-memory Directory API for this family's tests — an in-memory `fetch` handed to
3
+ * Effect's real `FetchHttpClient` through its `Fetch` reference, mirroring
4
+ * `../netbox/fake-netbox.ts` and `../forgejo/fake-forgejo.ts`. Tests exercise distilled's REAL
5
+ * path assembly, JSON encode/decode and status→typed-error matching (`404` → `NotFound`, `403` →
6
+ * `Forbidden`, …) — nothing about that matching is re-implemented here, only the wire responses a
7
+ * scenario needs.
8
+ *
9
+ * ⛔ TEST-ONLY, AND NO NETWORK. The token is a placeholder that is never a real bearer token, and
10
+ * no request this fake answers ever leaves the process.
11
+ */
12
+ import { type Credentials, fromAccessToken } from '@distilled.cloud/google-workspace/Credentials';
13
+ import * as Layer from 'effect/Layer';
14
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
15
+
16
+ export const FAKE_TOKEN = 'placeholder-google-workspace-token';
17
+
18
+ export type Seen = { readonly method: string; readonly path: string };
19
+
20
+ /** Google's own `{ error: { code, message, status } }` failure envelope — matching protocol.ts. */
21
+ export const fakeFailure = (status: number, message: string, googleStatus: string) =>
22
+ Response.json({ error: { code: status, message, status: googleStatus } }, { status });
23
+
24
+ /**
25
+ * `route` decides every response; this wrapper only records what was sent and adapts to
26
+ * `globalThis.fetch`'s signature. A test that expects no request at all asserts `seen` is empty.
27
+ */
28
+ export const fakeGoogleWorkspace = (route: (method: string, url: URL) => Response) => {
29
+ const seen: Seen[] = [];
30
+ const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
31
+ const request =
32
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
33
+ const url = new URL(request.url);
34
+ seen.push({ method: request.method, path: `${url.pathname}${url.search}` });
35
+ return route(request.method, url);
36
+ }) as typeof globalThis.fetch;
37
+ return { fetch, seen };
38
+ };
39
+
40
+ /**
41
+ * distilled Credentials + the real FetchHttpClient over the fake — `GoogleWorkspaceOpContext`,
42
+ * what every resource file's exported `spec.fetchLive` needs.
43
+ *
44
+ * ⚠️ NOT PAIRED WITH `handlers` (each resource file's `googleWorkspaceHandlers(spec)` export).
45
+ * `handlers` bakes in `CredentialsFromEnv`, which resolves `GOOGLE_ACCESS_TOKEN` through
46
+ * Effect's `Config` — whose default provider snapshots `process.env` once and never re-reads it
47
+ * (measured in the forgejo migration, kit PR 180). So a test cannot point `handlers` at a fake
48
+ * server; it calls `spec.fetchLive`/`spec.attributes` directly instead, with this explicit
49
+ * layer, exercising exactly the same production code without that env seam.
50
+ */
51
+ export const fakeGoogleWorkspaceLayer = (
52
+ fetchFn: typeof globalThis.fetch,
53
+ creds: Layer.Layer<Credentials> = fromAccessToken({ accessToken: FAKE_TOKEN }),
54
+ ) => Layer.mergeAll(creds, FetchHttpClient.layer, Layer.succeed(FetchHttpClient.Fetch, fetchFn));