@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.
- package/README.md +42 -0
- package/dist/caddy/config-reconcile.d.ts.map +1 -1
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/format-warnings.d.ts +29 -0
- package/dist/caddy/format-warnings.d.ts.map +1 -0
- package/dist/caddy/format.d.ts +50 -0
- package/dist/caddy/format.d.ts.map +1 -0
- package/dist/caddy/index.d.ts +1 -0
- package/dist/caddy/index.d.ts.map +1 -1
- package/dist/caddy/index.js +78 -10
- package/dist/caddy/index.js.map +7 -5
- package/dist/discord/application-command.d.ts +52 -0
- package/dist/discord/application-command.d.ts.map +1 -0
- package/dist/discord/command-form.d.ts +62 -0
- package/dist/discord/command-form.d.ts.map +1 -0
- package/dist/discord/credentials.d.ts +35 -0
- package/dist/discord/credentials.d.ts.map +1 -0
- package/dist/discord/fake-discord.d.ts +44 -0
- package/dist/discord/fake-discord.d.ts.map +1 -0
- package/dist/discord/guild-application-command.d.ts +53 -0
- package/dist/discord/guild-application-command.d.ts.map +1 -0
- package/dist/discord/index.d.ts +12 -0
- package/dist/discord/index.d.ts.map +1 -0
- package/dist/discord/index.js +193 -0
- package/dist/discord/index.js.map +15 -0
- package/dist/discord/providers.d.ts +15 -0
- package/dist/discord/providers.d.ts.map +1 -0
- package/dist/discord/resource.d.ts +75 -0
- package/dist/discord/resource.d.ts.map +1 -0
- package/dist/google-workspace/credentials.d.ts +54 -0
- package/dist/google-workspace/credentials.d.ts.map +1 -0
- package/dist/google-workspace/domain-alias.d.ts +74 -0
- package/dist/google-workspace/domain-alias.d.ts.map +1 -0
- package/dist/google-workspace/fake-google-workspace.d.ts +41 -0
- package/dist/google-workspace/fake-google-workspace.d.ts.map +1 -0
- package/dist/google-workspace/group-member.d.ts +73 -0
- package/dist/google-workspace/group-member.d.ts.map +1 -0
- package/dist/google-workspace/group.d.ts +87 -0
- package/dist/google-workspace/group.d.ts.map +1 -0
- package/dist/google-workspace/index.d.ts +19 -0
- package/dist/google-workspace/index.d.ts.map +1 -0
- package/dist/google-workspace/index.js +283 -0
- package/dist/google-workspace/index.js.map +17 -0
- package/dist/google-workspace/org-unit.d.ts +75 -0
- package/dist/google-workspace/org-unit.d.ts.map +1 -0
- package/dist/google-workspace/providers.d.ts +16 -0
- package/dist/google-workspace/providers.d.ts.map +1 -0
- package/dist/google-workspace/resource.d.ts +80 -0
- package/dist/google-workspace/resource.d.ts.map +1 -0
- package/dist/google-workspace/values.d.ts +28 -0
- package/dist/google-workspace/values.d.ts.map +1 -0
- package/dist/grafana/credentials.d.ts +38 -0
- package/dist/grafana/credentials.d.ts.map +1 -0
- package/dist/grafana/datasource.d.ts +90 -0
- package/dist/grafana/datasource.d.ts.map +1 -0
- package/dist/grafana/fake-grafana.d.ts +48 -0
- package/dist/grafana/fake-grafana.d.ts.map +1 -0
- package/dist/grafana/index.d.ts +17 -0
- package/dist/grafana/index.d.ts.map +1 -0
- package/dist/grafana/index.js +149 -0
- package/dist/grafana/index.js.map +13 -0
- package/dist/grafana/providers.d.ts +17 -0
- package/dist/grafana/providers.d.ts.map +1 -0
- package/dist/grafana/resource.d.ts +68 -0
- package/dist/grafana/resource.d.ts.map +1 -0
- package/docs/caddy-fmt.md +59 -0
- package/docs/caddy.md +7 -0
- package/docs/credentials.md +20 -0
- package/docs/discord.md +129 -0
- package/docs/google-workspace.md +105 -0
- package/docs/grafana.md +102 -0
- package/docs/peers.md +9 -0
- package/docs/upstream-conformance.md +53 -0
- package/package.json +13 -1
- package/src/caddy/config-format-warning.test.ts +91 -0
- package/src/caddy/config-reconcile.ts +6 -0
- package/src/caddy/config.ts +8 -1
- package/src/caddy/format-warnings.test.ts +50 -0
- package/src/caddy/format-warnings.ts +39 -0
- package/src/caddy/format.test.ts +105 -0
- package/src/caddy/format.ts +121 -0
- package/src/caddy/index.ts +7 -0
- package/src/caddy/providers.test.ts +8 -0
- package/src/discord/application-command.test.ts +103 -0
- package/src/discord/application-command.ts +109 -0
- package/src/discord/command-form.test.ts +144 -0
- package/src/discord/command-form.ts +133 -0
- package/src/discord/credentials.ts +36 -0
- package/src/discord/fake-discord.ts +70 -0
- package/src/discord/guild-application-command.test.ts +215 -0
- package/src/discord/guild-application-command.ts +129 -0
- package/src/discord/index.ts +31 -0
- package/src/discord/providers.ts +18 -0
- package/src/discord/resource.ts +164 -0
- package/src/discord/retry.test.ts +74 -0
- package/src/google-workspace/credentials.test.ts +40 -0
- package/src/google-workspace/credentials.ts +75 -0
- package/src/google-workspace/domain-alias.test.ts +69 -0
- package/src/google-workspace/domain-alias.ts +116 -0
- package/src/google-workspace/fake-google-workspace.ts +54 -0
- package/src/google-workspace/group-member.test.ts +91 -0
- package/src/google-workspace/group-member.ts +126 -0
- package/src/google-workspace/group.test.ts +93 -0
- package/src/google-workspace/group.ts +128 -0
- package/src/google-workspace/index.ts +54 -0
- package/src/google-workspace/org-unit.test.ts +81 -0
- package/src/google-workspace/org-unit.ts +143 -0
- package/src/google-workspace/providers.ts +26 -0
- package/src/google-workspace/resource.test.ts +186 -0
- package/src/google-workspace/resource.ts +162 -0
- package/src/google-workspace/values.ts +36 -0
- package/src/grafana/credentials.ts +65 -0
- package/src/grafana/datasource.test.ts +183 -0
- package/src/grafana/datasource.ts +171 -0
- package/src/grafana/fake-grafana.ts +64 -0
- package/src/grafana/index.ts +22 -0
- package/src/grafana/providers.ts +19 -0
- package/src/grafana/resource.ts +120 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GoogleWorkspace.OrgUnit` — one organizational unit (`admin/directory/v1/customer/{id}/orgunits`).
|
|
3
|
+
*
|
|
4
|
+
* ★ `orgUnitPath` IS THE IDENTITY the way `NetboxPrefix.prefix` is. Google's own doc line is
|
|
5
|
+
* copied onto `OrgUnitProps.orgUnitPath` below because it is the one field this resource cannot
|
|
6
|
+
* get wrong: it is DERIVED on read (`parentOrgUnitPath` + `name`) but AUTHORED as a full path on
|
|
7
|
+
* write, and the API's own path-parameter form drops the leading `/` (`GetOrgunitsRequest`'s
|
|
8
|
+
* `orgUnitPath` doc: "the full path … minus the leading `/`"). `values.ts`'s
|
|
9
|
+
* `stripLeadingSlash`/`withLeadingSlash` are the one place that asymmetry is handled.
|
|
10
|
+
*
|
|
11
|
+
* ⛔ THIS RESOURCE DOES NOT REMOVE SUB-UNITS OR MOVE USERS. Deleting an org unit that still has
|
|
12
|
+
* children or members fails server-side (Directory refuses rather than cascading) — nothing
|
|
13
|
+
* here retries around that refusal, matching S11's "observe before deleting", not "force
|
|
14
|
+
* through".
|
|
15
|
+
*
|
|
16
|
+
* ⛔ `defaultRemovalPolicy: 'retain'` — an org unit governs which Workspace services a user can
|
|
17
|
+
* reach (Google's own doc line on `orgUnitPath`); deleting one out from under live users is
|
|
18
|
+
* exactly the kind of hard-to-reverse action this policy exists to require an explicit opt-in
|
|
19
|
+
* for.
|
|
20
|
+
*/
|
|
21
|
+
import { Resource } from 'alchemy';
|
|
22
|
+
import * as Provider from 'alchemy/Provider';
|
|
23
|
+
import * as directory from '@distilled.cloud/google-workspace/unstable/admin_directory_v1';
|
|
24
|
+
import * as Effect from 'effect/Effect';
|
|
25
|
+
import type { GoogleWorkspaceRequirements, GoogleWorkspaceSpec } from './resource.ts';
|
|
26
|
+
import { googleWorkspaceHandlers } from './resource.ts';
|
|
27
|
+
import { stripLeadingSlash, text, withLeadingSlash } from './values.ts';
|
|
28
|
+
|
|
29
|
+
export interface OrgUnitProps {
|
|
30
|
+
/** The unique id for the Workspace account, or `my_customer` for the caller's own account. */
|
|
31
|
+
customerId?: string;
|
|
32
|
+
/**
|
|
33
|
+
* The full path to the organizational unit, e.g. `/Contractors/2026`. Required. In order to
|
|
34
|
+
* edit an `orgUnitPath`, either update `name` or `parentOrgUnitPath` — this field is derived on
|
|
35
|
+
* read, not itself patchable, so declaring a different path here plans a `replace`.
|
|
36
|
+
*/
|
|
37
|
+
orgUnitPath: string;
|
|
38
|
+
/** The parent's full path, e.g. `/Contractors`. Omit for a top-level unit under `/`. */
|
|
39
|
+
parentOrgUnitPath?: string;
|
|
40
|
+
description?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface OrgUnitAttributes {
|
|
44
|
+
customerId: string;
|
|
45
|
+
orgUnitPath: string;
|
|
46
|
+
parentOrgUnitPath: string;
|
|
47
|
+
name: string;
|
|
48
|
+
description: string;
|
|
49
|
+
orgUnitId: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface GoogleWorkspaceOrgUnit extends Resource<
|
|
53
|
+
'GoogleWorkspace.OrgUnit',
|
|
54
|
+
OrgUnitProps,
|
|
55
|
+
OrgUnitAttributes,
|
|
56
|
+
never,
|
|
57
|
+
GoogleWorkspaceRequirements
|
|
58
|
+
> {}
|
|
59
|
+
|
|
60
|
+
export const GoogleWorkspaceOrgUnit = Resource<GoogleWorkspaceOrgUnit>('GoogleWorkspace.OrgUnit', {
|
|
61
|
+
defaultRemovalPolicy: 'retain',
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const customerOf = (props: OrgUnitProps): string => props.customerId ?? 'my_customer';
|
|
65
|
+
/** The path segment the API wants, split from its own leading-name component for `name`/`insert`. */
|
|
66
|
+
const nameOf = (props: OrgUnitProps): string =>
|
|
67
|
+
props.orgUnitPath.split('/').filter(Boolean).pop() ?? props.orgUnitPath;
|
|
68
|
+
const parentOf = (props: OrgUnitProps): string => props.parentOrgUnitPath ?? '/';
|
|
69
|
+
|
|
70
|
+
const attributesOf = (
|
|
71
|
+
live: directory.OrgUnit,
|
|
72
|
+
props: OrgUnitProps,
|
|
73
|
+
): OrgUnitAttributes | undefined => {
|
|
74
|
+
const livePath = live.orgUnitPath === undefined ? undefined : withLeadingSlash(live.orgUnitPath);
|
|
75
|
+
if (livePath === undefined || livePath.toLowerCase() !== props.orgUnitPath.toLowerCase()) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
customerId: customerOf(props),
|
|
80
|
+
description: text(live.description),
|
|
81
|
+
name: text(live.name),
|
|
82
|
+
orgUnitId: text(live.orgUnitId),
|
|
83
|
+
orgUnitPath: livePath,
|
|
84
|
+
parentOrgUnitPath: text(live.parentOrgUnitPath),
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** ★ EXPORTED for direct testing — see org-unit.test.ts. */
|
|
89
|
+
export const spec: GoogleWorkspaceSpec<
|
|
90
|
+
OrgUnitProps,
|
|
91
|
+
directory.OrgUnit,
|
|
92
|
+
OrgUnitAttributes,
|
|
93
|
+
| directory.GetOrgunitsError
|
|
94
|
+
| directory.InsertOrgunitsError
|
|
95
|
+
| directory.PatchOrgunitsError
|
|
96
|
+
| directory.DeleteOrgunitsError
|
|
97
|
+
> = {
|
|
98
|
+
attributes: attributesOf,
|
|
99
|
+
create: (props) =>
|
|
100
|
+
directory.insertOrgunits({
|
|
101
|
+
body: {
|
|
102
|
+
description: props.description ?? '',
|
|
103
|
+
name: nameOf(props),
|
|
104
|
+
parentOrgUnitPath: parentOf(props),
|
|
105
|
+
},
|
|
106
|
+
customerId: customerOf(props),
|
|
107
|
+
}),
|
|
108
|
+
describe: (props) => `customer/${customerOf(props)}/orgunits${props.orgUnitPath}`,
|
|
109
|
+
destroy: (props, live) =>
|
|
110
|
+
directory.deleteOrgunits({
|
|
111
|
+
customerId: customerOf(props),
|
|
112
|
+
orgUnitPath: stripLeadingSlash(text(live.orgUnitPath) || props.orgUnitPath),
|
|
113
|
+
}),
|
|
114
|
+
fetchLive: (props) =>
|
|
115
|
+
directory
|
|
116
|
+
.getOrgunits({
|
|
117
|
+
customerId: customerOf(props),
|
|
118
|
+
orgUnitPath: stripLeadingSlash(props.orgUnitPath),
|
|
119
|
+
})
|
|
120
|
+
.pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
|
|
121
|
+
matches: (attributes, props) =>
|
|
122
|
+
attributes.description === (props.description ?? '') &&
|
|
123
|
+
attributes.parentOrgUnitPath === parentOf(props) &&
|
|
124
|
+
attributes.name === nameOf(props),
|
|
125
|
+
update: (props, live) =>
|
|
126
|
+
directory.patchOrgunits({
|
|
127
|
+
body: {
|
|
128
|
+
description: props.description ?? '',
|
|
129
|
+
name: nameOf(props),
|
|
130
|
+
parentOrgUnitPath: parentOf(props),
|
|
131
|
+
},
|
|
132
|
+
customerId: customerOf(props),
|
|
133
|
+
orgUnitPath: stripLeadingSlash(text(live.orgUnitPath) || props.orgUnitPath),
|
|
134
|
+
}),
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export const handlers = googleWorkspaceHandlers(spec);
|
|
138
|
+
|
|
139
|
+
export const GoogleWorkspaceOrgUnitProvider = () =>
|
|
140
|
+
Provider.effect(
|
|
141
|
+
GoogleWorkspaceOrgUnit,
|
|
142
|
+
Effect.succeed(GoogleWorkspaceOrgUnit.Provider.of(handlers)),
|
|
143
|
+
);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every Google Workspace provider, wired once — the same role `litellmProviders`/`netboxProviders`-
|
|
3
|
+
* style helpers play elsewhere in this kit (S16: "a subpath's `providers()` plays the same role" as
|
|
4
|
+
* upstream's `Providers.ts`).
|
|
5
|
+
*
|
|
6
|
+
* Layer.mergeAll(googleWorkspaceProviders(), …the stack's other providers)
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ CREDENTIALS ARE NOT A PARAMETER HERE. Every handler already closes over
|
|
9
|
+
* `CredentialsFromEnv` inside `resource.ts` (`GOOGLE_ACCESS_TOKEN`, resolved at call time — see
|
|
10
|
+
* credentials.ts), so this layer only wires the four resource providers together. `HttpClient`
|
|
11
|
+
* is NOT provided here either — it is ambient in every Alchemy runtime, the same note
|
|
12
|
+
* `litellm/providers.ts` makes for its own family.
|
|
13
|
+
*/
|
|
14
|
+
import * as Layer from 'effect/Layer';
|
|
15
|
+
import { GoogleWorkspaceDomainAliasProvider } from './domain-alias.ts';
|
|
16
|
+
import { GoogleWorkspaceGroupProvider } from './group.ts';
|
|
17
|
+
import { GoogleWorkspaceGroupMemberProvider } from './group-member.ts';
|
|
18
|
+
import { GoogleWorkspaceOrgUnitProvider } from './org-unit.ts';
|
|
19
|
+
|
|
20
|
+
export const googleWorkspaceProviders = () =>
|
|
21
|
+
Layer.mergeAll(
|
|
22
|
+
GoogleWorkspaceGroupProvider(),
|
|
23
|
+
GoogleWorkspaceGroupMemberProvider(),
|
|
24
|
+
GoogleWorkspaceDomainAliasProvider(),
|
|
25
|
+
GoogleWorkspaceOrgUnitProvider(),
|
|
26
|
+
);
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `googleWorkspaceOperations`'s shared `diff`/`reconcile` engine, exercised end-to-end through a
|
|
3
|
+
* fake Directory API — not just through a resource's own `spec.matches`/`fetchLive` in isolation.
|
|
4
|
+
*
|
|
5
|
+
* 🔴 WHY THIS FILE EXISTS. An adversarial review of this family (2026-09-24) found that every
|
|
6
|
+
* other test here calls `spec.fetchLive`/`spec.attributes`/`spec.matches` directly and never
|
|
7
|
+
* `googleWorkspaceOperations(spec).diff`/`.reconcile` — so a bug INSIDE `resource.ts` itself
|
|
8
|
+
* (shared by all four resources) could invert `diff`'s `noop`/`update` branches, or skip the
|
|
9
|
+
* "write returned no error but the object is still absent" die-guard, and no test here would
|
|
10
|
+
* catch it. This file closes that gap: it drives the real engine against real specs (`group.ts`,
|
|
11
|
+
* whose spec HAS `update`, and `domain-alias.ts`, whose spec does NOT), through the same fake
|
|
12
|
+
* HTTP layer every other test in this family uses.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, test } from 'bun:test';
|
|
15
|
+
import * as Effect from 'effect/Effect';
|
|
16
|
+
import { spec as aliasSpec } from './domain-alias.ts';
|
|
17
|
+
import {
|
|
18
|
+
fakeFailure,
|
|
19
|
+
fakeGoogleWorkspace,
|
|
20
|
+
fakeGoogleWorkspaceLayer,
|
|
21
|
+
} from './fake-google-workspace.ts';
|
|
22
|
+
import { spec as groupSpec } from './group.ts';
|
|
23
|
+
import { googleWorkspaceOperations } from './resource.ts';
|
|
24
|
+
|
|
25
|
+
const groupOps = googleWorkspaceOperations(groupSpec);
|
|
26
|
+
const groupProps = { email: 'ops@schenanigans.com', name: 'Ops', description: 'ops team' };
|
|
27
|
+
const GROUP_PATH = '/admin/directory/v1/groups/ops%40schenanigans.com';
|
|
28
|
+
|
|
29
|
+
describe('googleWorkspaceOperations(group spec).reconcile', () => {
|
|
30
|
+
test('absent → create, then read back — the happy create path', async () => {
|
|
31
|
+
let created = false;
|
|
32
|
+
const fake = fakeGoogleWorkspace((method, url) => {
|
|
33
|
+
if (method === 'POST' && url.pathname === '/admin/directory/v1/groups') {
|
|
34
|
+
created = true;
|
|
35
|
+
return Response.json({ email: groupProps.email, name: 'Ops' });
|
|
36
|
+
}
|
|
37
|
+
if (method === 'GET' && url.pathname === GROUP_PATH) {
|
|
38
|
+
return created
|
|
39
|
+
? Response.json({ description: 'ops team', email: groupProps.email, name: 'Ops' })
|
|
40
|
+
: fakeFailure(404, 'not found', 'NOT_FOUND');
|
|
41
|
+
}
|
|
42
|
+
return fakeFailure(404, 'not found', 'NOT_FOUND');
|
|
43
|
+
});
|
|
44
|
+
const attrs = await Effect.runPromise(
|
|
45
|
+
groupOps.reconcile(groupProps).pipe(Effect.provide(fakeGoogleWorkspaceLayer(fake.fetch))),
|
|
46
|
+
);
|
|
47
|
+
expect(created).toBe(true);
|
|
48
|
+
expect(attrs.email).toBe(groupProps.email);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('present and matching → no PATCH is ever sent', async () => {
|
|
52
|
+
const fake = fakeGoogleWorkspace((method, url) =>
|
|
53
|
+
method === 'GET' && url.pathname === GROUP_PATH
|
|
54
|
+
? Response.json({ description: 'ops team', email: groupProps.email, name: 'Ops' })
|
|
55
|
+
: fakeFailure(404, 'not found', 'NOT_FOUND'),
|
|
56
|
+
);
|
|
57
|
+
await Effect.runPromise(
|
|
58
|
+
groupOps.reconcile(groupProps).pipe(Effect.provide(fakeGoogleWorkspaceLayer(fake.fetch))),
|
|
59
|
+
);
|
|
60
|
+
expect(fake.seen.some((r) => r.method === 'PATCH')).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('present and drifted → PATCH is sent exactly once', async () => {
|
|
64
|
+
let patched = false;
|
|
65
|
+
const fake = fakeGoogleWorkspace((method, url) => {
|
|
66
|
+
if (method === 'PATCH' && url.pathname === GROUP_PATH) {
|
|
67
|
+
patched = true;
|
|
68
|
+
return Response.json({ description: 'ops team', email: groupProps.email, name: 'Ops' });
|
|
69
|
+
}
|
|
70
|
+
if (method === 'GET' && url.pathname === GROUP_PATH) {
|
|
71
|
+
return Response.json({
|
|
72
|
+
description: 'stale description',
|
|
73
|
+
email: groupProps.email,
|
|
74
|
+
name: 'Ops',
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return fakeFailure(404, 'not found', 'NOT_FOUND');
|
|
78
|
+
});
|
|
79
|
+
await Effect.runPromise(
|
|
80
|
+
groupOps.reconcile(groupProps).pipe(Effect.provide(fakeGoogleWorkspaceLayer(fake.fetch))),
|
|
81
|
+
);
|
|
82
|
+
expect(patched).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('a write that reports success but leaves the object absent dies — never lies to the plan', async () => {
|
|
86
|
+
const fake = fakeGoogleWorkspace(
|
|
87
|
+
(method, url) =>
|
|
88
|
+
method === 'POST' && url.pathname === '/admin/directory/v1/groups'
|
|
89
|
+
? Response.json({})
|
|
90
|
+
: fakeFailure(404, 'not found', 'NOT_FOUND'), // every GET stays 404, even after "create"
|
|
91
|
+
);
|
|
92
|
+
const failed = await Effect.runPromiseExit(
|
|
93
|
+
groupOps.reconcile(groupProps).pipe(Effect.provide(fakeGoogleWorkspaceLayer(fake.fetch))),
|
|
94
|
+
);
|
|
95
|
+
expect(failed._tag).toBe('Failure');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('googleWorkspaceOperations(group spec).diff', () => {
|
|
100
|
+
const live = {
|
|
101
|
+
adminCreated: false,
|
|
102
|
+
aliases: [],
|
|
103
|
+
description: 'ops team',
|
|
104
|
+
directMembersCount: '0',
|
|
105
|
+
email: groupProps.email,
|
|
106
|
+
groupId: 'x',
|
|
107
|
+
name: 'Ops',
|
|
108
|
+
nonEditableAliases: [],
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
test('output undefined → undefined (engine default: treat as update)', async () => {
|
|
112
|
+
// ⚠️ `diff` short-circuits on `output === undefined` before it ever reads — no request is
|
|
113
|
+
// made — but the Effect's TYPE still carries `GoogleWorkspaceOpContext`, so a layer is
|
|
114
|
+
// still required to satisfy `runPromise`. `fake.seen` below proves the short-circuit.
|
|
115
|
+
const fake = fakeGoogleWorkspace(() => fakeFailure(500, 'must not be called', 'INTERNAL'));
|
|
116
|
+
const result = await Effect.runPromise(
|
|
117
|
+
groupOps
|
|
118
|
+
.diff(groupProps, undefined)
|
|
119
|
+
.pipe(Effect.provide(fakeGoogleWorkspaceLayer(fake.fetch))),
|
|
120
|
+
);
|
|
121
|
+
expect(result).toBeUndefined();
|
|
122
|
+
expect(fake.seen).toEqual([]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('matching live → noop, read through the real fake', async () => {
|
|
126
|
+
const fake = fakeGoogleWorkspace((method, url) =>
|
|
127
|
+
method === 'GET' && url.pathname === GROUP_PATH
|
|
128
|
+
? Response.json({ description: 'ops team', email: groupProps.email, name: 'Ops' })
|
|
129
|
+
: fakeFailure(404, 'not found', 'NOT_FOUND'),
|
|
130
|
+
);
|
|
131
|
+
const result = await Effect.runPromise(
|
|
132
|
+
groupOps.diff(groupProps, live).pipe(Effect.provide(fakeGoogleWorkspaceLayer(fake.fetch))),
|
|
133
|
+
);
|
|
134
|
+
expect(result).toEqual({ action: 'noop' });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('drifted live, spec HAS update → action is update, not replace', async () => {
|
|
138
|
+
const fake = fakeGoogleWorkspace((method, url) =>
|
|
139
|
+
method === 'GET' && url.pathname === GROUP_PATH
|
|
140
|
+
? Response.json({
|
|
141
|
+
description: 'a different description',
|
|
142
|
+
email: groupProps.email,
|
|
143
|
+
name: 'Ops',
|
|
144
|
+
})
|
|
145
|
+
: fakeFailure(404, 'not found', 'NOT_FOUND'),
|
|
146
|
+
);
|
|
147
|
+
const result = await Effect.runPromise(
|
|
148
|
+
groupOps.diff(groupProps, live).pipe(Effect.provide(fakeGoogleWorkspaceLayer(fake.fetch))),
|
|
149
|
+
);
|
|
150
|
+
expect(result).toEqual({ action: 'update' });
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('googleWorkspaceOperations(domain-alias spec).diff — spec has NO update', () => {
|
|
155
|
+
const aliasOps = googleWorkspaceOperations(aliasSpec);
|
|
156
|
+
const aliasProps = {
|
|
157
|
+
domainAliasName: 'alias.schenanigans.com',
|
|
158
|
+
parentDomainName: 'schenanigans.com',
|
|
159
|
+
};
|
|
160
|
+
const ALIAS_PATH =
|
|
161
|
+
'/admin/directory/v1/customer/my_customer/domainaliases/alias.schenanigans.com';
|
|
162
|
+
const liveAttrs = {
|
|
163
|
+
creationTime: '0',
|
|
164
|
+
customer: 'my_customer',
|
|
165
|
+
domainAliasName: aliasProps.domainAliasName,
|
|
166
|
+
parentDomainName: aliasProps.parentDomainName,
|
|
167
|
+
verified: true,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
test('drifted live, spec has NO update → replace, never update', async () => {
|
|
171
|
+
const fake = fakeGoogleWorkspace((method, url) =>
|
|
172
|
+
method === 'GET' && url.pathname === ALIAS_PATH
|
|
173
|
+
? Response.json({
|
|
174
|
+
domainAliasName: aliasProps.domainAliasName,
|
|
175
|
+
parentDomainName: 'homeflare.dev',
|
|
176
|
+
})
|
|
177
|
+
: fakeFailure(404, 'not found', 'NOT_FOUND'),
|
|
178
|
+
);
|
|
179
|
+
const result = await Effect.runPromise(
|
|
180
|
+
aliasOps
|
|
181
|
+
.diff(aliasProps, liveAttrs)
|
|
182
|
+
.pipe(Effect.provide(fakeGoogleWorkspaceLayer(fake.fetch))),
|
|
183
|
+
);
|
|
184
|
+
expect(result).toEqual({ action: 'replace' });
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One shape for every Google Workspace Directory object — the same four operations, different
|
|
3
|
+
* distilled call.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ THE ALTERNATIVE IS COPYING THE SAME HANDLERS PER RESOURCE. Groups, group members, domain
|
|
6
|
+
* aliases and org units are all create / read / update / delete against
|
|
7
|
+
* `@distilled.cloud/google-workspace`'s typed `admin_directory_v1` operations. Hand-rolling the
|
|
8
|
+
* fourth copy is where someone drops the read-back or the `isResolved` guard and the plan lies.
|
|
9
|
+
*
|
|
10
|
+
* ★ SO THE FOUR OPERATIONS LIVE HERE, ONCE — mirroring `../forgejo/resource.ts`'s engine exactly.
|
|
11
|
+
* The Directory API addresses every object this family models by a stable key (an email, a
|
|
12
|
+
* domain alias name, an org unit path), so — unlike NetBox's page-then-disambiguate `locateOne`
|
|
13
|
+
* — `fetchLive` is always a plain get-by-key.
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ `NotFound` IS FOLDED TO "ABSENT" INSIDE EACH RESOURCE FILE'S OWN `fetchLive`, NOT HERE — the
|
|
16
|
+
* same split forgejo's and netbox's engines use. `Effect.catchTag`'s tag-literal inference needs
|
|
17
|
+
* a concrete error union to resolve `"NotFound"` against; a spec's `E` is only concrete at each
|
|
18
|
+
* resource file's own call site.
|
|
19
|
+
*
|
|
20
|
+
* ★ ADOPT IS THE DEFAULT POSTURE, NOT A FLAG. `reconcile` LOCATES before it writes: a group or org
|
|
21
|
+
* unit the Workspace already has is bound, not duplicated — Tim's own domain (schenanigans.com)
|
|
22
|
+
* predates this code. `spec.attributes` always maps the FULL live object, so a bound row reads
|
|
23
|
+
* back byte-identical to what Workspace actually holds — the exact-as-live half of `adopt(true)`;
|
|
24
|
+
* the other half (refusing to bind without it) is upstream's `AdoptPolicy`, unchanged here.
|
|
25
|
+
*
|
|
26
|
+
* ⛔ CREDENTIALS ARE NEVER A PROP (S24/S25). See `credentials.ts` for what a stack passes instead.
|
|
27
|
+
*/
|
|
28
|
+
import { isResolved } from 'alchemy/Diff';
|
|
29
|
+
import type { Input } from 'alchemy/Input';
|
|
30
|
+
import { CredentialsFromEnv } from '@distilled.cloud/google-workspace/Credentials';
|
|
31
|
+
import type { GoogleWorkspaceOpContext } from '@distilled.cloud/google-workspace/Protocol';
|
|
32
|
+
import * as Effect from 'effect/Effect';
|
|
33
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* ★ WHAT EVERY HANDLER NEEDS FROM THE CALLER'S RUNTIME, after Credentials are provided below.
|
|
37
|
+
* `GoogleWorkspaceOpContext` is `Credentials | HttpClient.HttpClient`; `googleWorkspaceHandlers`
|
|
38
|
+
* closes over `CredentialsFromEnv` itself (`GOOGLE_ACCESS_TOKEN` / `GOOGLE_PROJECT_ID` — see
|
|
39
|
+
* credentials.ts), so a consuming stack only has to provide `HttpClient.HttpClient`, the same
|
|
40
|
+
* shape every other distilled-backed family in this kit asks for.
|
|
41
|
+
*/
|
|
42
|
+
export type GoogleWorkspaceRequirements = HttpClient.HttpClient;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One Directory object's distilled calls. `Live` is whatever the SDK decodes (a `Group`, a
|
|
46
|
+
* `Member`, a `DomainAlias`, an `OrgUnit`) — never an untyped response body.
|
|
47
|
+
*
|
|
48
|
+
* `E` is left to each resource file to declare (the union of the distilled operations it
|
|
49
|
+
* actually calls), so a call site's precise per-operation error union flows straight through
|
|
50
|
+
* instead of being widened by hand here.
|
|
51
|
+
*/
|
|
52
|
+
export type GoogleWorkspaceSpec<Props extends object, Live, Attributes, E> = {
|
|
53
|
+
/** Already folds a real "absent" outcome — a `catchTag('NotFound', ...)` at the call site. */
|
|
54
|
+
readonly fetchLive: (
|
|
55
|
+
props: Props,
|
|
56
|
+
) => Effect.Effect<Live | undefined, E, GoogleWorkspaceOpContext>;
|
|
57
|
+
readonly attributes: (live: Live, props: Props) => Attributes | undefined;
|
|
58
|
+
readonly matches: (attributes: Attributes, props: Props) => boolean;
|
|
59
|
+
/** Absent for an existence-only family (none here yet, but `GroupMember` could become one). */
|
|
60
|
+
readonly create?: (props: Props) => Effect.Effect<unknown, E, GoogleWorkspaceOpContext>;
|
|
61
|
+
readonly update?: (
|
|
62
|
+
props: Props,
|
|
63
|
+
live: Live,
|
|
64
|
+
) => Effect.Effect<unknown, E, GoogleWorkspaceOpContext>;
|
|
65
|
+
readonly destroy: (
|
|
66
|
+
props: Props,
|
|
67
|
+
live: Live,
|
|
68
|
+
) => Effect.Effect<unknown, E, GoogleWorkspaceOpContext>;
|
|
69
|
+
/** For the read-back-failure message. */
|
|
70
|
+
readonly describe: (props: Props) => string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const googleWorkspaceOperations = <Props extends object, Live, Attributes, E>(
|
|
74
|
+
spec: GoogleWorkspaceSpec<Props, Live, Attributes, E>,
|
|
75
|
+
) => {
|
|
76
|
+
const read = (props: Props) =>
|
|
77
|
+
spec
|
|
78
|
+
.fetchLive(props)
|
|
79
|
+
.pipe(Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))));
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
read,
|
|
83
|
+
|
|
84
|
+
diff: (news: Input<Props>, output: Attributes | undefined) =>
|
|
85
|
+
Effect.gen(function* () {
|
|
86
|
+
if (output === undefined || !isResolved(news)) return undefined;
|
|
87
|
+
const live = yield* read(news);
|
|
88
|
+
if (live === undefined) return { action: 'update' } as const;
|
|
89
|
+
if (spec.matches(live, news)) return { action: 'noop' } as const;
|
|
90
|
+
return spec.update === undefined
|
|
91
|
+
? ({ action: 'replace' } as const)
|
|
92
|
+
: ({ action: 'update' } as const);
|
|
93
|
+
}),
|
|
94
|
+
|
|
95
|
+
reconcile: (news: Props) =>
|
|
96
|
+
Effect.gen(function* () {
|
|
97
|
+
const before = yield* spec.fetchLive(news);
|
|
98
|
+
if (before === undefined) {
|
|
99
|
+
if (spec.create === undefined) {
|
|
100
|
+
return yield* Effect.die(
|
|
101
|
+
new Error(`${spec.describe(news)}: spec declares no create for a missing object`),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
yield* spec.create(news);
|
|
105
|
+
} else if (spec.update !== undefined) {
|
|
106
|
+
const mapped = spec.attributes(before, news);
|
|
107
|
+
// ⚠️ ADOPTION IS THE `mapped === undefined` CASE TOO: a row this spec cannot map is not
|
|
108
|
+
// a row to PATCH blindly.
|
|
109
|
+
if (mapped !== undefined && !spec.matches(mapped, news)) {
|
|
110
|
+
yield* spec.update(news, before);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const after = yield* read(news);
|
|
114
|
+
if (after === undefined) {
|
|
115
|
+
return yield* Effect.die(
|
|
116
|
+
new Error(
|
|
117
|
+
`${spec.describe(news)}: the write returned no error but the object is still ` +
|
|
118
|
+
'absent. Directory returns the object it wrote — read back rather than trusting ' +
|
|
119
|
+
'the status code.',
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return after;
|
|
124
|
+
}),
|
|
125
|
+
|
|
126
|
+
destroy: (olds: Props) =>
|
|
127
|
+
Effect.gen(function* () {
|
|
128
|
+
const live = yield* spec.fetchLive(olds);
|
|
129
|
+
if (live === undefined) return;
|
|
130
|
+
yield* spec.destroy(olds, live);
|
|
131
|
+
}),
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The five provider handlers for a spec'd Directory object, wired once.
|
|
137
|
+
*
|
|
138
|
+
* ★ CREDENTIALS ARE PROVIDED HERE, ONCE, NOT BY EVERY CALLER. `CredentialsFromEnv` resolves
|
|
139
|
+
* `GOOGLE_ACCESS_TOKEN` on the calling fiber per request (credentials.ts's own note), so this
|
|
140
|
+
* closure keeps that laziness — nothing is captured at module load.
|
|
141
|
+
*
|
|
142
|
+
* ⛔ IT STOPS AT THE HANDLERS AND DOES NOT RETURN THE LAYER — same reasoning as `forgejoHandlers`.
|
|
143
|
+
*
|
|
144
|
+
* ⚠️ `list` answers empty. The Directory API's list endpoints page the whole domain — every group,
|
|
145
|
+
* every org unit — so adoption stays explicit rather than sweeping (same posture as NetBox and
|
|
146
|
+
* Forgejo; the census handoff script, not this provider, is where a full inventory belongs).
|
|
147
|
+
*/
|
|
148
|
+
export const googleWorkspaceHandlers = <Props extends object, Live, Attributes extends object, E>(
|
|
149
|
+
spec: GoogleWorkspaceSpec<Props, Live, Attributes, E>,
|
|
150
|
+
) => {
|
|
151
|
+
const ops = googleWorkspaceOperations(spec);
|
|
152
|
+
const withCredentials = <A>(effect: Effect.Effect<A, E, GoogleWorkspaceOpContext>) =>
|
|
153
|
+
Effect.provide(effect, CredentialsFromEnv);
|
|
154
|
+
return {
|
|
155
|
+
list: () => Effect.succeed([]),
|
|
156
|
+
read: ({ olds }: { olds: Props }) => withCredentials(ops.read(olds)),
|
|
157
|
+
diff: ({ news, output }: { news: Input<Props>; output: Attributes | undefined }) =>
|
|
158
|
+
withCredentials(ops.diff(news, output)),
|
|
159
|
+
reconcile: ({ news }: { news: Props }) => withCredentials(ops.reconcile(news)),
|
|
160
|
+
delete: ({ olds }: { olds: Props }) => withCredentials(ops.destroy(olds)),
|
|
161
|
+
};
|
|
162
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire coercions shared across Google Workspace resources.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ EVERY FIELD ON A GENERATED DIRECTORY TYPE IS `S.optional(...)` (the SDK does not know which
|
|
5
|
+
* fields Google always sends), so a live read is `string | undefined` even for a field the API
|
|
6
|
+
* guarantees in practice. These helpers turn that into the settled value a `matches()` compares
|
|
7
|
+
* against, mirroring `../netbox/values.ts` and `../forgejo/values.ts` for the same reason: a
|
|
8
|
+
* `diff` that compared `undefined` against a declared default would see drift forever.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** `''` for absent — Directory objects that omit a text field mean "no value", not `undefined`. */
|
|
12
|
+
export const text = (value: string | undefined): string => value ?? '';
|
|
13
|
+
|
|
14
|
+
export const bool = (value: boolean | undefined): boolean => value === true;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Wire arrays compared in sorted order. `StringList` (group aliases, org unit paths in a search)
|
|
18
|
+
* decodes to `string[] | undefined`; Directory does not promise an order.
|
|
19
|
+
*/
|
|
20
|
+
export const stringArray = (value: readonly string[] | undefined): string[] =>
|
|
21
|
+
value === undefined ? [] : [...value].toSorted();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Every user- and group-facing key in this family is case-insensitive on the wire (Google lower-
|
|
25
|
+
* cases email addresses and org unit path segments internally) but a declaration may be typed in
|
|
26
|
+
* mixed case. Compare through this, never with `===`, or an adopted object drifts on every plan.
|
|
27
|
+
*/
|
|
28
|
+
export const ciEqual = (left: string, right: string): boolean =>
|
|
29
|
+
left.toLowerCase() === right.toLowerCase();
|
|
30
|
+
|
|
31
|
+
/** An org unit path as the API wants it for a path segment: no leading `/`. */
|
|
32
|
+
export const stripLeadingSlash = (path: string): string => path.replace(/^\/+/, '');
|
|
33
|
+
|
|
34
|
+
/** An org unit path as attributes should read it back: exactly one leading `/`. */
|
|
35
|
+
export const withLeadingSlash = (path: string): string =>
|
|
36
|
+
path.startsWith('/') ? path : `/${path}`;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grafana credentials for this family — a URL plus a TOKEN REFERENCE (an env var NAME), never a
|
|
3
|
+
* literal secret. Wraps `@distilled.cloud/grafana`'s own `Credentials` context directly (S23 —
|
|
4
|
+
* reuse the SDK's own client rather than inventing a second one); this file only adds the house's
|
|
5
|
+
* lazy, per-instance resolution on top of it.
|
|
6
|
+
*
|
|
7
|
+
* ★ ONE `GRAFANA_TOKEN`/`GRAFANA_URL` PAIR ISN'T ENOUGH FOR THIS HOUSE. Unlike Forgejo (one
|
|
8
|
+
* instance), the estate runs at least two Grafanas — `grafana.homeflare.dev` and
|
|
9
|
+
* `teslamate-grafana` on CT100, which the unit's own comment says has "Own Access SaaS OIDC
|
|
10
|
+
* client, never grafana.homeflare.dev's". The SDK's `CredentialsFromEnv` reads one fixed pair
|
|
11
|
+
* of names, so a stack declaring against more than one instance needs a target per instance —
|
|
12
|
+
* `grafanaCredentials(target)` below, parameterized the way `litellm/credentials.ts` and
|
|
13
|
+
* `proxmox/credentials.ts` already are in this kit.
|
|
14
|
+
*
|
|
15
|
+
* ⛔ THE TOKEN NEVER TOUCHES A PROP OR AN ATTRIBUTE (S25). `tokenEnv` is a NAME, resolved from
|
|
16
|
+
* `process.env` fresh inside the credentials effect on every request (S24) — a layer built once
|
|
17
|
+
* still re-reads the environment each time an operation runs, the same laziness
|
|
18
|
+
* `litellm/credentials.ts` and the SDK's own `forgejo`/`netbox` `CredentialsFromEnv` have.
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ THE SDK's `Credentials` SERVICE VALUE TYPE IS `Effect.Effect<Config, never, never>` — the
|
|
21
|
+
* error channel is `never` (credentials.d.ts). A missing env var can only surface as a defect
|
|
22
|
+
* here, via `Effect.orDie`, the same trade-off `forgejo/resource.ts` and `netbox/resource.ts`
|
|
23
|
+
* already accept for their own `CredentialsFromEnv`; it is not a choice this file introduces.
|
|
24
|
+
*/
|
|
25
|
+
import { type Config, Credentials } from '@distilled.cloud/grafana/Credentials';
|
|
26
|
+
import { ConfigError } from '@distilled.cloud/grafana/Errors';
|
|
27
|
+
import * as Effect from 'effect/Effect';
|
|
28
|
+
import * as Layer from 'effect/Layer';
|
|
29
|
+
import * as Redacted from 'effect/Redacted';
|
|
30
|
+
|
|
31
|
+
export interface GrafanaTarget {
|
|
32
|
+
/** Instance origin, e.g. `https://tesla.homeflare.dev/dash` — never `/api`; the SDK appends it. */
|
|
33
|
+
readonly baseUrl: string;
|
|
34
|
+
/** Env var NAME holding a Grafana service-account token. Never the value itself. */
|
|
35
|
+
readonly tokenEnv: string;
|
|
36
|
+
/** Selects the org a call applies to, via `X-Grafana-Org-Id`. Omit for the default org. */
|
|
37
|
+
readonly orgId?: number | string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const resolve = (target: GrafanaTarget): Effect.Effect<Config, never, never> =>
|
|
41
|
+
Effect.sync(() => process.env[target.tokenEnv]).pipe(
|
|
42
|
+
Effect.flatMap((raw) =>
|
|
43
|
+
raw === undefined || raw.trim() === ''
|
|
44
|
+
? Effect.fail(
|
|
45
|
+
new ConfigError({
|
|
46
|
+
message:
|
|
47
|
+
`${target.tokenEnv} is not set. Export the Grafana service-account token there ` +
|
|
48
|
+
`— never as an Alchemy prop — before running a stack that declares a resource ` +
|
|
49
|
+
`against ${target.baseUrl}.`,
|
|
50
|
+
}),
|
|
51
|
+
)
|
|
52
|
+
: Effect.succeed({
|
|
53
|
+
apiBaseUrl: target.baseUrl.replace(/\/+$/, ''),
|
|
54
|
+
token: Redacted.make(raw.trim()),
|
|
55
|
+
...(target.orgId === undefined ? {} : { orgId: target.orgId }),
|
|
56
|
+
} satisfies Config),
|
|
57
|
+
),
|
|
58
|
+
Effect.orDie,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
/** The lazy, per-instance layer `grafanaProviders(target)` composes by default. */
|
|
62
|
+
export const grafanaCredentials = (target: GrafanaTarget): Layer.Layer<Credentials> =>
|
|
63
|
+
Layer.succeed(Credentials, resolve(target));
|
|
64
|
+
|
|
65
|
+
export type { Credentials };
|