@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,91 @@
1
+ /**
2
+ * `GoogleWorkspace.GroupMember`'s `spec` against a fake Directory API — unlike
3
+ * `Forgejo.TeamMember`, this membership has mutable fields (`role`, `deliverySettings`), so
4
+ * `matches` can see real drift, not just existence.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import * as Effect from 'effect/Effect';
8
+ import {
9
+ fakeFailure,
10
+ fakeGoogleWorkspace,
11
+ fakeGoogleWorkspaceLayer,
12
+ } from './fake-google-workspace.ts';
13
+ import { spec } from './group-member.ts';
14
+
15
+ // ⚠️ `@` is percent-encoded by the SDK's own path-token encoder — match the wire form (see the
16
+ // same note in group.test.ts).
17
+ const MEMBER_PATH =
18
+ '/admin/directory/v1/groups/team%40schenanigans.com/members/tim%40schenanigans.com';
19
+ const props = {
20
+ email: 'tim@schenanigans.com',
21
+ group: 'team@schenanigans.com',
22
+ role: 'OWNER' as const,
23
+ };
24
+
25
+ const readThrough = (fetchFn: typeof globalThis.fetch) =>
26
+ Effect.runPromise(
27
+ spec.fetchLive(props).pipe(
28
+ Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))),
29
+ Effect.provide(fakeGoogleWorkspaceLayer(fetchFn)),
30
+ ),
31
+ );
32
+
33
+ describe('GoogleWorkspace.GroupMember spec.fetchLive + spec.attributes', () => {
34
+ test('a live member decodes with its role and delivery settings', async () => {
35
+ const fake = fakeGoogleWorkspace((method, url) =>
36
+ method === 'GET' && url.pathname === MEMBER_PATH
37
+ ? Response.json({
38
+ delivery_settings: 'ALL_MAIL',
39
+ email: 'tim@schenanigans.com',
40
+ id: 'member-1',
41
+ role: 'OWNER',
42
+ status: 'ACTIVE',
43
+ })
44
+ : fakeFailure(404, 'not found', 'NOT_FOUND'),
45
+ );
46
+ expect(await readThrough(fake.fetch)).toEqual({
47
+ deliverySettings: 'ALL_MAIL',
48
+ email: 'tim@schenanigans.com',
49
+ group: 'team@schenanigans.com',
50
+ memberId: 'member-1',
51
+ role: 'OWNER',
52
+ status: 'ACTIVE',
53
+ });
54
+ });
55
+
56
+ test('a 404 folds to undefined', async () => {
57
+ const fake = fakeGoogleWorkspace(() => fakeFailure(404, 'not found', 'NOT_FOUND'));
58
+ expect(await readThrough(fake.fetch)).toBeUndefined();
59
+ });
60
+ });
61
+
62
+ describe('GoogleWorkspace.GroupMember spec.matches — role and delivery drift', () => {
63
+ const live = {
64
+ deliverySettings: 'ALL_MAIL',
65
+ email: 'tim@schenanigans.com',
66
+ group: 'team@schenanigans.com',
67
+ memberId: 'member-1',
68
+ role: 'MEMBER',
69
+ status: 'ACTIVE',
70
+ };
71
+
72
+ test('a declared role different from live is drift', () => {
73
+ expect(spec.matches(live, props)).toBe(false);
74
+ });
75
+
76
+ test('the same role, and an undeclared deliverySettings converging to ALL_MAIL, is no drift', () => {
77
+ expect(spec.matches({ ...live, role: 'OWNER' }, props)).toBe(true);
78
+ });
79
+
80
+ test('a declared deliverySettings different from live is drift even with a matching role', () => {
81
+ expect(
82
+ spec.matches(
83
+ { ...live, deliverySettings: 'DIGEST', role: 'OWNER' },
84
+ {
85
+ ...props,
86
+ deliverySettings: 'NONE',
87
+ },
88
+ ),
89
+ ).toBe(false);
90
+ });
91
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * `GoogleWorkspace.GroupMember` — one user or group's membership in one Directory group.
3
+ *
4
+ * ★ WHY THIS DOES NOT MODEL A `GoogleWorkspace.User`. `email` here identifies whoever the group
5
+ * member is (a user's primary email, or another group's), the same way Forgejo's
6
+ * `TeamMember.username` (team-member.ts) never creates the account it references. The Directory
7
+ * API needs no user resource to exist in this family for a membership to be declared — see
8
+ * group.ts's own note on why users are out of scope.
9
+ *
10
+ * ⚠️ UNLIKE `Forgejo.TeamMember`, A MEMBERSHIP HERE IS NOT EXISTENCE-ONLY. `role`
11
+ * (`OWNER`/`MANAGER`/`MEMBER`) and `deliverySettings` (`ALL_MAIL`/`DIGEST`/`DAILY`/`NONE`/
12
+ * `DISABLED`) are both mutable in place via `patchMembers` — so this spec declares `update`,
13
+ * not `upsert`, the same shape `group.ts`/`domain-alias.ts` use.
14
+ *
15
+ * ⛔ GROUP CYCLES ARE THE VENDOR'S TO REFUSE, NOT THIS FAMILY'S. `insertMembers`/`patchMembers`
16
+ * surface a cycle as `BadRequest`, which this spec's error union already carries un-narrowed —
17
+ * nothing here guesses at "member is itself an ancestor group".
18
+ */
19
+ import { Resource } from 'alchemy';
20
+ import * as Provider from 'alchemy/Provider';
21
+ import * as directory from '@distilled.cloud/google-workspace/unstable/admin_directory_v1';
22
+ import * as Effect from 'effect/Effect';
23
+ import type { GoogleWorkspaceRequirements, GoogleWorkspaceSpec } from './resource.ts';
24
+ import { googleWorkspaceHandlers } from './resource.ts';
25
+ import { ciEqual, text } from './values.ts';
26
+
27
+ export type MemberRole = 'OWNER' | 'MANAGER' | 'MEMBER';
28
+ export type DeliverySettings = 'ALL_MAIL' | 'DIGEST' | 'DAILY' | 'NONE' | 'DISABLED';
29
+
30
+ export interface GroupMemberProps {
31
+ /** The group's email, alias or id — same key `Group.email` declares. */
32
+ group: string;
33
+ /** The member's email (a user or another group). Primary key alongside `group`. */
34
+ email: string;
35
+ /** @default 'MEMBER' */
36
+ role?: MemberRole;
37
+ /** @default 'ALL_MAIL' */
38
+ deliverySettings?: DeliverySettings;
39
+ }
40
+
41
+ export interface GroupMemberAttributes {
42
+ group: string;
43
+ email: string;
44
+ memberId: string;
45
+ role: string;
46
+ deliverySettings: string;
47
+ /** Read-only — `ACTIVE` for a normal member, `PENDING` while an invite is outstanding. */
48
+ status: string;
49
+ }
50
+
51
+ export interface GoogleWorkspaceGroupMember extends Resource<
52
+ 'GoogleWorkspace.GroupMember',
53
+ GroupMemberProps,
54
+ GroupMemberAttributes,
55
+ never,
56
+ GoogleWorkspaceRequirements
57
+ > {}
58
+
59
+ export const GoogleWorkspaceGroupMember = Resource<GoogleWorkspaceGroupMember>(
60
+ 'GoogleWorkspace.GroupMember',
61
+ );
62
+
63
+ const defaultRole = (props: GroupMemberProps): MemberRole => props.role ?? 'MEMBER';
64
+ const defaultDelivery = (props: GroupMemberProps): DeliverySettings =>
65
+ props.deliverySettings ?? 'ALL_MAIL';
66
+
67
+ const attributesOf = (
68
+ live: directory.Member,
69
+ props: GroupMemberProps,
70
+ ): GroupMemberAttributes | undefined => {
71
+ if (live.email === undefined || !ciEqual(live.email, props.email)) return undefined;
72
+ return {
73
+ deliverySettings: text(live.delivery_settings),
74
+ email: live.email,
75
+ group: props.group,
76
+ memberId: text(live.id),
77
+ role: text(live.role),
78
+ status: text(live.status),
79
+ };
80
+ };
81
+
82
+ /** ★ EXPORTED for direct testing — see group-member.test.ts. */
83
+ export const spec: GoogleWorkspaceSpec<
84
+ GroupMemberProps,
85
+ directory.Member,
86
+ GroupMemberAttributes,
87
+ | directory.GetMembersError
88
+ | directory.InsertMembersError
89
+ | directory.PatchMembersError
90
+ | directory.DeleteMembersError
91
+ > = {
92
+ attributes: attributesOf,
93
+ create: (props) =>
94
+ directory.insertMembers({
95
+ body: {
96
+ delivery_settings: defaultDelivery(props),
97
+ email: props.email,
98
+ role: defaultRole(props),
99
+ },
100
+ groupKey: props.group,
101
+ }),
102
+ describe: (props) => `groups/${props.group}/members/${props.email}`,
103
+ destroy: (props, live) =>
104
+ directory.deleteMembers({ groupKey: props.group, memberKey: text(live.id) || props.email }),
105
+ fetchLive: (props) =>
106
+ directory
107
+ .getMembers({ groupKey: props.group, memberKey: props.email })
108
+ .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
109
+ matches: (attributes, props) =>
110
+ attributes.role === defaultRole(props) &&
111
+ attributes.deliverySettings === defaultDelivery(props),
112
+ update: (props, live) =>
113
+ directory.patchMembers({
114
+ body: { delivery_settings: defaultDelivery(props), role: defaultRole(props) },
115
+ groupKey: props.group,
116
+ memberKey: text(live.id) || props.email,
117
+ }),
118
+ };
119
+
120
+ export const handlers = googleWorkspaceHandlers(spec);
121
+
122
+ export const GoogleWorkspaceGroupMemberProvider = () =>
123
+ Provider.effect(
124
+ GoogleWorkspaceGroupMember,
125
+ Effect.succeed(GoogleWorkspaceGroupMember.Provider.of(handlers)),
126
+ );
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `GoogleWorkspace.Group`'s `spec` against a fake Directory API — get-by-key, unlike NetBox's
3
+ * list-then-disambiguate (resource.ts's own note on why this family never needs `locateOne`).
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 './group.ts';
13
+
14
+ // ⚠️ `@` is percent-encoded by the SDK's own path-token encoder (`encodeURIComponent`), and
15
+ // `URL#pathname` does not decode an already-encoded sequence back — match the wire form.
16
+ const GROUP_PATH = '/admin/directory/v1/groups/team%40schenanigans.com';
17
+ const props = { email: 'team@schenanigans.com', name: 'Team', description: 'the team' };
18
+
19
+ const readThrough = (fetchFn: typeof globalThis.fetch) =>
20
+ Effect.runPromise(
21
+ spec.fetchLive(props).pipe(
22
+ Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))),
23
+ Effect.provide(fakeGoogleWorkspaceLayer(fetchFn)),
24
+ ),
25
+ );
26
+
27
+ describe('GoogleWorkspace.Group spec.fetchLive + spec.attributes', () => {
28
+ test('a live group decodes into the full attribute set — adopt(true) exact-as-live', async () => {
29
+ const fake = fakeGoogleWorkspace((method, url) =>
30
+ method === 'GET' && url.pathname === GROUP_PATH
31
+ ? Response.json({
32
+ adminCreated: true,
33
+ aliases: ['old-team@schenanigans.com'],
34
+ description: 'the team',
35
+ directMembersCount: '3',
36
+ email: 'team@schenanigans.com',
37
+ id: '01234567890',
38
+ name: 'Team',
39
+ })
40
+ : fakeFailure(404, 'not found', 'NOT_FOUND'),
41
+ );
42
+ expect(await readThrough(fake.fetch)).toEqual({
43
+ adminCreated: true,
44
+ aliases: ['old-team@schenanigans.com'],
45
+ description: 'the team',
46
+ directMembersCount: '3',
47
+ email: 'team@schenanigans.com',
48
+ groupId: '01234567890',
49
+ name: 'Team',
50
+ nonEditableAliases: [],
51
+ });
52
+ });
53
+
54
+ test('a 404 folds to undefined — the standard "not created yet" case', async () => {
55
+ const fake = fakeGoogleWorkspace(() => fakeFailure(404, 'not found', 'NOT_FOUND'));
56
+ expect(await readThrough(fake.fetch)).toBeUndefined();
57
+ });
58
+
59
+ test('a 403 propagates unfolded — never treated as absent', async () => {
60
+ const fake = fakeGoogleWorkspace(() => fakeFailure(403, 'forbidden', 'PERMISSION_DENIED'));
61
+ const failure = await Effect.runPromise(
62
+ Effect.flip(spec.fetchLive(props).pipe(Effect.provide(fakeGoogleWorkspaceLayer(fake.fetch)))),
63
+ );
64
+ expect(failure._tag).toBe('Forbidden');
65
+ });
66
+ });
67
+
68
+ describe('GoogleWorkspace.Group spec.matches', () => {
69
+ const live = {
70
+ adminCreated: false,
71
+ aliases: [],
72
+ description: 'the team',
73
+ directMembersCount: '0',
74
+ email: 'team@schenanigans.com',
75
+ groupId: 'x',
76
+ name: 'Team',
77
+ nonEditableAliases: [],
78
+ };
79
+
80
+ test('declared description and name equal to live — no drift', () => {
81
+ expect(spec.matches(live, props)).toBe(true);
82
+ });
83
+
84
+ test('a changed description is drift', () => {
85
+ expect(spec.matches(live, { ...props, description: 'a different team' })).toBe(false);
86
+ });
87
+
88
+ test('an undeclared name converges to the email local-part deterministically', () => {
89
+ const { name: _name, ...withoutName } = props;
90
+ expect(spec.matches({ ...live, name: 'team' }, withoutName)).toBe(true);
91
+ expect(spec.matches(live, withoutName)).toBe(false);
92
+ });
93
+ });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `GoogleWorkspace.Group` — one Admin SDK Directory group (`admin/directory/v1/groups`).
3
+ *
4
+ * ★ WHY A GROUP IS THE FIRST OBJECT CLASS, AND NOT A USER. The estate's own Cloudflare Access
5
+ * policies already reference Workspace groups by name ("Schenanigans - Admin", "Schenanigans -
6
+ * Google" — `docs/zero-trust-access-applications.md` in homeflare-landscape) as the Google IdP
7
+ * groups an Access rule allows. A group is also the one Directory object with no foreign-key
8
+ * prerequisite: `email` is the whole identity, the same reasoning `netbox/prefix.ts` gives for
9
+ * starting with `Prefix` over `Device`.
10
+ *
11
+ * ⛔ USERS ARE OUT OF SCOPE FOR THIS FAMILY. `insertUsers`/`patchUsers`/`updateUsers` all accept a
12
+ * `password` field on the wire `User` schema — modelling `GoogleWorkspace.User` here would put a
13
+ * credential-shaped field one keystroke away from becoming a prop, and Alchemy persists props
14
+ * unencrypted (S25). No stack in this estate declares a Workspace user today (measured
15
+ * 2026-09-24 — see docs/upstream-conformance.md), so there is no house need forcing the
16
+ * question. `GroupMember` (group-member.ts) references a user by email without ever creating,
17
+ * reading or storing one.
18
+ *
19
+ * ★ ADOPT-FRIENDLY BY CONSTRUCTION. `groupKey` accepts an email, alias or id; this family always
20
+ * uses `email` so `fetchLive` binds the live group by the same key a human would type. An
21
+ * instance that already has `admin@schenanigans.com`'s "Schenanigans - Admin" group is BOUND,
22
+ * never duplicated, and `attributes` maps the full live row — the exact-as-live half of
23
+ * `adopt(true)` (resource.ts's own note).
24
+ *
25
+ * ⛔ THIS RESOURCE DOES NOT DELETE BY DEFAULT. `defaultRemovalPolicy: 'retain'` — removing a group
26
+ * an Access policy still names would break sign-in for everyone that rule allows. `delete` stays
27
+ * fully implemented; a caller opts in with `.pipe(RemovalPolicy.destroy())`.
28
+ *
29
+ * ⛔ NO CREDENTIAL IS A PROP. `GOOGLE_ACCESS_TOKEN` is read from the environment at call time,
30
+ * through `@distilled.cloud/google-workspace`'s `CredentialsFromEnv` (resource.ts) — see
31
+ * credentials.ts for where that token comes from.
32
+ */
33
+ import { Resource } from 'alchemy';
34
+ import * as Provider from 'alchemy/Provider';
35
+ import * as directory from '@distilled.cloud/google-workspace/unstable/admin_directory_v1';
36
+ import * as Effect from 'effect/Effect';
37
+ import type { GoogleWorkspaceRequirements, GoogleWorkspaceSpec } from './resource.ts';
38
+ import { googleWorkspaceHandlers } from './resource.ts';
39
+ import { ciEqual, stringArray, text } from './values.ts';
40
+
41
+ export interface GroupProps {
42
+ /** The group's email address — the primary key for locate, create and update alike. */
43
+ email: string;
44
+ /** Display name. @default the local part of `email` */
45
+ name?: string;
46
+ /** Free text, up to 4,096 characters per the Directory API. */
47
+ description?: string;
48
+ }
49
+
50
+ export interface GroupAttributes {
51
+ email: string;
52
+ name: string;
53
+ description: string;
54
+ groupId: string;
55
+ /** Read-only — direct members, as a decimal string on the wire (Directory's own choice). */
56
+ directMembersCount: string;
57
+ /** Read-only. Aliases outside the primary domain/subdomains; edited only via alias endpoints. */
58
+ nonEditableAliases: string[];
59
+ /** Read-only. Aliases within the account's domains; edited only via alias endpoints. */
60
+ aliases: string[];
61
+ adminCreated: boolean;
62
+ }
63
+
64
+ export interface GoogleWorkspaceGroup extends Resource<
65
+ 'GoogleWorkspace.Group',
66
+ GroupProps,
67
+ GroupAttributes,
68
+ never,
69
+ GoogleWorkspaceRequirements
70
+ > {}
71
+
72
+ export const GoogleWorkspaceGroup = Resource<GoogleWorkspaceGroup>('GoogleWorkspace.Group', {
73
+ defaultRemovalPolicy: 'retain',
74
+ });
75
+
76
+ /** Un-declared `name` converges here — deterministic, so an undeclared field never drifts. */
77
+ const defaultName = (props: GroupProps): string =>
78
+ props.name ?? props.email.split('@')[0] ?? props.email;
79
+
80
+ const attributesOf = (live: directory.Group, props: GroupProps): GroupAttributes | undefined => {
81
+ if (live.email === undefined || !ciEqual(live.email, props.email)) return undefined;
82
+ return {
83
+ adminCreated: live.adminCreated === true,
84
+ aliases: stringArray(live.aliases),
85
+ description: text(live.description),
86
+ directMembersCount: text(live.directMembersCount),
87
+ email: live.email,
88
+ groupId: text(live.id),
89
+ name: text(live.name),
90
+ nonEditableAliases: stringArray(live.nonEditableAliases),
91
+ };
92
+ };
93
+
94
+ /** ★ EXPORTED for direct testing with an explicit fake `Credentials` layer — see group.test.ts. */
95
+ export const spec: GoogleWorkspaceSpec<
96
+ GroupProps,
97
+ directory.Group,
98
+ GroupAttributes,
99
+ | directory.GetGroupsError
100
+ | directory.InsertGroupsError
101
+ | directory.PatchGroupsError
102
+ | directory.DeleteGroupsError
103
+ > = {
104
+ attributes: attributesOf,
105
+ create: (props) =>
106
+ directory.insertGroups({
107
+ body: { description: props.description ?? '', email: props.email, name: defaultName(props) },
108
+ }),
109
+ describe: (props) => `groups/${props.email}`,
110
+ destroy: (_props, live) =>
111
+ directory.deleteGroups({ groupKey: text(live.id) || text(live.email) }),
112
+ fetchLive: (props) =>
113
+ directory
114
+ .getGroups({ groupKey: props.email })
115
+ .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
116
+ matches: (attributes, props) =>
117
+ attributes.description === (props.description ?? '') && attributes.name === defaultName(props),
118
+ update: (props, live) =>
119
+ directory.patchGroups({
120
+ body: { description: props.description ?? '', name: defaultName(props) },
121
+ groupKey: text(live.id) || text(live.email),
122
+ }),
123
+ };
124
+
125
+ export const handlers = googleWorkspaceHandlers(spec);
126
+
127
+ export const GoogleWorkspaceGroupProvider = () =>
128
+ Provider.effect(GoogleWorkspaceGroup, Effect.succeed(GoogleWorkspaceGroup.Provider.of(handlers)));
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Google Workspace providers for Alchemy.
3
+ *
4
+ * ⛔ THIS BARREL IS THE PUBLIC API, AND IT IS DELIBERATELY SMALLER THAN THE DIRECTORY. These are
5
+ * the symbols a real stack consumes; the rest of the files are internals a provider needs but a
6
+ * consumer should not depend on. An `export *` here would publish every helper as API and make
7
+ * the next refactor a breaking change.
8
+ * ★ `credentials.ts`'s re-exports ARE published, because a stack needs `CredentialsFromEnv` (or
9
+ * `fromAccessToken` in a test) to build a runtime layer, and `GoogleWorkspaceKeyRef` /
10
+ * `describeKeyRef` are the documentation seam credential setup is meant to go through.
11
+ */
12
+ export {
13
+ type GoogleWorkspaceConfig,
14
+ Credentials,
15
+ CredentialsFromEnv,
16
+ describeKeyRef,
17
+ fromAccessToken,
18
+ GOOGLE_ACCESS_TOKEN_ENV,
19
+ GOOGLE_PROJECT_ID_ENV,
20
+ type GoogleWorkspaceKeyRef,
21
+ } from './credentials.ts';
22
+ export {
23
+ GoogleWorkspaceDomainAlias,
24
+ GoogleWorkspaceDomainAliasProvider,
25
+ type DomainAliasAttributes,
26
+ type DomainAliasProps,
27
+ } from './domain-alias.ts';
28
+ export {
29
+ GoogleWorkspaceGroup,
30
+ GoogleWorkspaceGroupProvider,
31
+ type GroupAttributes,
32
+ type GroupProps,
33
+ } from './group.ts';
34
+ export {
35
+ GoogleWorkspaceGroupMember,
36
+ GoogleWorkspaceGroupMemberProvider,
37
+ type DeliverySettings,
38
+ type GroupMemberAttributes,
39
+ type GroupMemberProps,
40
+ type MemberRole,
41
+ } from './group-member.ts';
42
+ export {
43
+ GoogleWorkspaceOrgUnit,
44
+ GoogleWorkspaceOrgUnitProvider,
45
+ type OrgUnitAttributes,
46
+ type OrgUnitProps,
47
+ } from './org-unit.ts';
48
+ export { googleWorkspaceProviders } from './providers.ts';
49
+ export {
50
+ googleWorkspaceHandlers,
51
+ googleWorkspaceOperations,
52
+ type GoogleWorkspaceRequirements,
53
+ type GoogleWorkspaceSpec,
54
+ } from './resource.ts';
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `GoogleWorkspace.OrgUnit`'s `spec` against a fake Directory API — proves the leading-slash
3
+ * asymmetry `values.ts`'s `stripLeadingSlash`/`withLeadingSlash` exist for (org-unit.ts's note).
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 './org-unit.ts';
13
+
14
+ const UNIT_PATH = '/admin/directory/v1/customer/my_customer/orgunits/Contractors/2026';
15
+ const props = {
16
+ orgUnitPath: '/Contractors/2026',
17
+ parentOrgUnitPath: '/Contractors',
18
+ description: '2026 cohort',
19
+ };
20
+
21
+ const readThrough = (fetchFn: typeof globalThis.fetch) =>
22
+ Effect.runPromise(
23
+ spec.fetchLive(props).pipe(
24
+ Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))),
25
+ Effect.provide(fakeGoogleWorkspaceLayer(fetchFn)),
26
+ ),
27
+ );
28
+
29
+ describe('GoogleWorkspace.OrgUnit spec.fetchLive + spec.attributes', () => {
30
+ test('the path is sent without its leading slash, and read back with one', async () => {
31
+ const fake = fakeGoogleWorkspace((method, url) =>
32
+ method === 'GET' && url.pathname === UNIT_PATH
33
+ ? Response.json({
34
+ description: '2026 cohort',
35
+ name: '2026',
36
+ orgUnitId: 'id:0123',
37
+ orgUnitPath: '/Contractors/2026',
38
+ parentOrgUnitPath: '/Contractors',
39
+ })
40
+ : fakeFailure(404, 'not found', 'NOT_FOUND'),
41
+ );
42
+ expect(await readThrough(fake.fetch)).toEqual({
43
+ customerId: 'my_customer',
44
+ description: '2026 cohort',
45
+ name: '2026',
46
+ orgUnitId: 'id:0123',
47
+ orgUnitPath: '/Contractors/2026',
48
+ parentOrgUnitPath: '/Contractors',
49
+ });
50
+ expect(fake.seen).toEqual([{ method: 'GET', path: UNIT_PATH }]);
51
+ });
52
+
53
+ test('a 404 folds to undefined', async () => {
54
+ const fake = fakeGoogleWorkspace(() => fakeFailure(404, 'not found', 'NOT_FOUND'));
55
+ expect(await readThrough(fake.fetch)).toBeUndefined();
56
+ });
57
+ });
58
+
59
+ describe('GoogleWorkspace.OrgUnit spec.matches', () => {
60
+ const live = {
61
+ customerId: 'my_customer',
62
+ description: '2026 cohort',
63
+ name: '2026',
64
+ orgUnitId: 'id:0123',
65
+ orgUnitPath: '/Contractors/2026',
66
+ parentOrgUnitPath: '/Contractors',
67
+ };
68
+
69
+ test('matching name, parent and description is no drift', () => {
70
+ expect(spec.matches(live, props)).toBe(true);
71
+ });
72
+
73
+ test('a moved parent is drift', () => {
74
+ expect(spec.matches(live, { ...props, parentOrgUnitPath: '/Employees' })).toBe(false);
75
+ });
76
+
77
+ test('an undeclared parentOrgUnitPath converges to the root, deterministically', () => {
78
+ const { parentOrgUnitPath: _p, ...rootProps } = props;
79
+ expect(spec.matches({ ...live, parentOrgUnitPath: '/' }, rootProps)).toBe(true);
80
+ });
81
+ });