@homeflare/alchemy 0.29.1 → 0.30.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 +20 -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/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 +11 -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 +37 -0
- package/package.json +9 -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/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,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));
|
|
@@ -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
|
+
});
|