blogwright-pds 0.3.2 → 0.4.0-beta.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/dist/client-metadata.d.ts +3 -3
- package/dist/client-metadata.js +3 -4
- package/dist/commands.d.ts +3 -3
- package/dist/commands.js +10 -11
- package/dist/config.d.ts +49 -0
- package/dist/config.js +94 -0
- package/dist/content.d.ts +3 -3
- package/dist/content.js +4 -4
- package/dist/context.d.ts +36 -26
- package/dist/context.js +4 -4
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -1
- package/dist/nodes.d.ts +72 -0
- package/dist/nodes.js +177 -0
- package/dist/oauth.d.ts +4 -4
- package/dist/oauth.js +14 -13
- package/dist/plugin.d.ts +72 -0
- package/dist/plugin.js +160 -0
- package/dist/rkey.d.ts +1 -1
- package/dist/rkey.js +2 -3
- package/dist/secret.d.ts +4 -4
- package/dist/secret.js +6 -7
- package/dist/sync.d.ts +17 -3
- package/dist/sync.js +22 -8
- package/dist/xrpc.d.ts +2 -2
- package/dist/xrpc.js +3 -4
- package/package.json +5 -2
- package/dist/client-metadata.js.map +0 -1
- package/dist/commands.js.map +0 -1
- package/dist/content.js.map +0 -1
- package/dist/context.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/oauth.js.map +0 -1
- package/dist/rkey.js.map +0 -1
- package/dist/secret.js.map +0 -1
- package/dist/sync.js.map +0 -1
- package/dist/test-support.d.ts +0 -32
- package/dist/test-support.js +0 -74
- package/dist/test-support.js.map +0 -1
- package/dist/xrpc.js.map +0 -1
package/dist/nodes.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The resource topography this plugin owns: one node attaching a
|
|
3
|
+
* `blogwright-pds`-named **inline policy** to the site's GitHub-OIDC deploy
|
|
4
|
+
* role, granting Secrets Manager access to the plugin's own secret.
|
|
5
|
+
*
|
|
6
|
+
* The grant lives here rather than in the site's resource graph because it is
|
|
7
|
+
* plugin topography: the site graph currently branches on `ctx.config.pds` and
|
|
8
|
+
* interpolates this plugin's secret name into an IAM ARN
|
|
9
|
+
* (`packages/cli/src/nodes.ts`), which is the CLI knowing something only this
|
|
10
|
+
* package should know. Moving it here is safe because IAM inline policies are
|
|
11
|
+
* *named*: the site's `<env>-deploy` document and this plugin's
|
|
12
|
+
* `blogwright-pds` document are independent objects on the same role, created,
|
|
13
|
+
* reconciled and removed with no read of each other. That is what makes the
|
|
14
|
+
* migration additive - both grants are live at once until the site's statement
|
|
15
|
+
* is removed a release later - and it is why `delete()` can safely remove this
|
|
16
|
+
* document without touching the role's own.
|
|
17
|
+
*
|
|
18
|
+
* The node's own state (the site's deploy role) is not something this plugin
|
|
19
|
+
* creates, so `read()` never reports the role - only whether the policy this
|
|
20
|
+
* plugin owns is attached to it.
|
|
21
|
+
*/
|
|
22
|
+
import { resolvePdsSecretName } from './config.js';
|
|
23
|
+
/** The graph id this plugin records the grant under, in its own scoped state. */
|
|
24
|
+
const NODE_ID = 'pds-oidc-policy';
|
|
25
|
+
/**
|
|
26
|
+
* The inline policy's name on the role. Deliberately the **package** name and
|
|
27
|
+
* not the `pds` CLI namespace: this string is on-the-wire IAM state that
|
|
28
|
+
* `delete()` has to find again through `listRolePolicies` on a later run, while
|
|
29
|
+
* a namespace is a local label a manifest can re-declare (core's
|
|
30
|
+
* `resolveNamespaceCollisions` renames a colliding one). A rename of the
|
|
31
|
+
* namespace must not orphan a live IAM object.
|
|
32
|
+
*/
|
|
33
|
+
const POLICY_NAME = 'blogwright-pds';
|
|
34
|
+
/**
|
|
35
|
+
* The site graph's node id for the GitHub OIDC deploy role
|
|
36
|
+
* (`packages/cli/src/nodes.ts`). Only the role's ARN is recorded there, which
|
|
37
|
+
* is why the *name* comes from `ctx.names.githubRole` and this id is used only
|
|
38
|
+
* to confirm the site has been bootstrapped.
|
|
39
|
+
*/
|
|
40
|
+
const SITE_OIDC_ROLE_NODE = 'gh-oidc-role';
|
|
41
|
+
/**
|
|
42
|
+
* The shared preview stack's environment name - the one environment this grant
|
|
43
|
+
* must never reach. The CLI spells it as a literal at its single call site
|
|
44
|
+
* (`runPreview`, `packages/cli/src/cli.ts`), so there is no exported constant
|
|
45
|
+
* to import; see {@link buildPdsNodes} for why the skip is written against
|
|
46
|
+
* `ctx.env` and not `ctx.preview`.
|
|
47
|
+
*/
|
|
48
|
+
const PREVIEW_ENV = 'preview';
|
|
49
|
+
/** The three actions the post-deploy PDS sync needs: it reads the OAuth secret and writes the rotated session back. */
|
|
50
|
+
const SECRET_ACTIONS = [
|
|
51
|
+
'secretsmanager:GetSecretValue',
|
|
52
|
+
'secretsmanager:PutSecretValue',
|
|
53
|
+
'secretsmanager:CreateSecret',
|
|
54
|
+
];
|
|
55
|
+
/**
|
|
56
|
+
* The policy document, scoped to this plugin's own secret. The trailing `-*`
|
|
57
|
+
* matches the six random characters Secrets Manager appends to a secret's ARN.
|
|
58
|
+
* A `*` resource here would hand the deploy role every secret in the account,
|
|
59
|
+
* including every other plugin's - the scoping is the point of the node.
|
|
60
|
+
*/
|
|
61
|
+
function policyDocument(ctx, secretName) {
|
|
62
|
+
return {
|
|
63
|
+
Version: '2012-10-17',
|
|
64
|
+
Statement: [
|
|
65
|
+
{
|
|
66
|
+
Effect: 'Allow',
|
|
67
|
+
Action: SECRET_ACTIONS,
|
|
68
|
+
Resource: `arn:aws:secretsmanager:${ctx.config.region}:${ctx.accountId}:secret:${secretName}-*`,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The deploy role's name, once the site's state proves the role exists. The
|
|
75
|
+
* name itself is always derivable (`deriveNames`), so the presence check is
|
|
76
|
+
* against the site's recorded ARN - the only thing the site's node records for
|
|
77
|
+
* that role - and not against the name.
|
|
78
|
+
*/
|
|
79
|
+
function requireRoleName(ctx) {
|
|
80
|
+
if (ctx.siteState.resources[SITE_OIDC_ROLE_NODE]?.arn === undefined) {
|
|
81
|
+
throw new Error(`the site's GitHub OIDC deploy role is not in the "${ctx.env}" state - run \`blogwright bootstrap --env ${ctx.env}\` before \`blogwright pds bootstrap\``);
|
|
82
|
+
}
|
|
83
|
+
return ctx.names.githubRole;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The plugin's grant on the site's deploy role. `secretName` is resolved once,
|
|
87
|
+
* at graph-build time, where the `pds` block's presence is already the
|
|
88
|
+
* condition for building the node at all.
|
|
89
|
+
*/
|
|
90
|
+
function oidcPolicyNode(secretName) {
|
|
91
|
+
function recordGrant(ctx, roleName) {
|
|
92
|
+
ctx.record(NODE_ID, { roleName, policyName: POLICY_NAME });
|
|
93
|
+
}
|
|
94
|
+
async function applyPolicy(ctx) {
|
|
95
|
+
const roleName = requireRoleName(ctx);
|
|
96
|
+
await ctx.clients.iam.putRolePolicy(roleName, POLICY_NAME, policyDocument(ctx, secretName));
|
|
97
|
+
recordGrant(ctx, roleName);
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
id: NODE_ID,
|
|
101
|
+
// The role belongs to the site's graph, not this one - there is no node
|
|
102
|
+
// here to depend on. `requireRoleName` is what orders this after the site.
|
|
103
|
+
dependsOn: [],
|
|
104
|
+
title: 'PDS secret access on the deploy role',
|
|
105
|
+
async read(ctx) {
|
|
106
|
+
const roleName = requireRoleName(ctx);
|
|
107
|
+
if (!(await ctx.clients.iam.listRolePolicies(roleName)).includes(POLICY_NAME))
|
|
108
|
+
return false;
|
|
109
|
+
recordGrant(ctx, roleName);
|
|
110
|
+
return true;
|
|
111
|
+
},
|
|
112
|
+
create: applyPolicy,
|
|
113
|
+
// Re-put rather than leave the document as found: a changed `secretName`
|
|
114
|
+
// must reach the live policy, exactly as the site's own role node
|
|
115
|
+
// re-applies its document on every `blogwright bootstrap`.
|
|
116
|
+
update: applyPolicy,
|
|
117
|
+
async delete(ctx) {
|
|
118
|
+
// No role recorded means no role, and therefore no document of ours on
|
|
119
|
+
// it. Teardown after `blogwright destroy` is a no-op here, not the
|
|
120
|
+
// `blogwright bootstrap` failure the apply path raises: there is nothing
|
|
121
|
+
// an operator could do to make it succeed.
|
|
122
|
+
if (ctx.siteState.resources[SITE_OIDC_ROLE_NODE]?.arn === undefined)
|
|
123
|
+
return;
|
|
124
|
+
const roleName = ctx.names.githubRole;
|
|
125
|
+
// Delete only this plugin's own named document, and only when it is
|
|
126
|
+
// there - a second `pds destroy` must not fail on a NoSuchEntity, and no
|
|
127
|
+
// other policy name on this shared role is ever read or written.
|
|
128
|
+
if (!(await ctx.clients.iam.listRolePolicies(roleName)).includes(POLICY_NAME))
|
|
129
|
+
return;
|
|
130
|
+
await ctx.clients.iam.deleteRolePolicy(roleName, POLICY_NAME);
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The nodes `blogwright-pds` contributes, for the plugin's `nodes(ctx)` member
|
|
136
|
+
* to return.
|
|
137
|
+
*
|
|
138
|
+
* Empty - a no-op `pds bootstrap`, not a failure - in three cases:
|
|
139
|
+
*
|
|
140
|
+
* 1. The site configures no `pds` block.
|
|
141
|
+
* 2. `config.githubRepo` is unset. A non-preview site graph only adds the
|
|
142
|
+
* deploy role when it is set (`packages/cli/src/nodes.ts`), so such a site
|
|
143
|
+
* is fully bootstrapped and simply has no role to attach to.
|
|
144
|
+
* 3. The target environment is the shared **preview** stack. That one is a
|
|
145
|
+
* privilege boundary, not an absence: the preview stack's role does exist
|
|
146
|
+
* (`buildNodes` adds `githubOidcRoleNode(true)` for it unconditionally),
|
|
147
|
+
* but its OIDC trust policy accepts the subject claim `repo:<owner>/<repo>:*`
|
|
148
|
+
* - **any ref** - where production accepts only the release-gated
|
|
149
|
+
* `repo:...:environment:production` (`oidcSubClaim`). The site's own graph
|
|
150
|
+
* withholds exactly this Secrets Manager statement from that role for that
|
|
151
|
+
* reason, and `resolvePdsSecretName` is environment-independent
|
|
152
|
+
* (`<siteName>/atproto`), so there is ONE PDS credential for the whole
|
|
153
|
+
* site: granting it here would let anyone who can push a branch read or
|
|
154
|
+
* rotate the site's ATProto session. `staging` is NOT a preview stack -
|
|
155
|
+
* the site already grants it this statement - so the skip is on the
|
|
156
|
+
* preview environment specifically, never on "not production".
|
|
157
|
+
*
|
|
158
|
+
* The preview check is `ctx.env === PREVIEW_ENV` and deliberately **not**
|
|
159
|
+
* `ctx.preview`, which would be dead code here: `runPlugin` builds its
|
|
160
|
+
* `ContextOptions` with no `preview` key at all
|
|
161
|
+
* (`packages/cli/src/plugin-commands.ts`), `createContext` defaults the flag to
|
|
162
|
+
* `false` (`context.ts`) and `toPluginContext` copies it, so `ctx.preview` is
|
|
163
|
+
* `false` for every plugin context the CLI builds - including the one
|
|
164
|
+
* `blogwright pds bootstrap --env preview` produces. `ctx.env` is what carries
|
|
165
|
+
* the stack's identity into a plugin.
|
|
166
|
+
*
|
|
167
|
+
* The `pds` block is read from `ctx.config`, not `ctx.pluginConfig`: core's
|
|
168
|
+
* `OpsConfig` still declares `pds` as a typed member (it keeps the type after
|
|
169
|
+
* the validation moves here), and the CLI does not populate `pluginConfig`
|
|
170
|
+
* from a plugin's own validator until later in this migration.
|
|
171
|
+
*/
|
|
172
|
+
export function buildPdsNodes(ctx) {
|
|
173
|
+
const pds = ctx.config.pds;
|
|
174
|
+
if (!pds || !ctx.config.githubRepo || ctx.env === PREVIEW_ENV)
|
|
175
|
+
return [];
|
|
176
|
+
return [oidcPolicyNode(resolvePdsSecretName(pds, ctx.config.siteName))];
|
|
177
|
+
}
|
package/dist/oauth.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* atproto OAuth (confidential client) for standard.site publishing
|
|
2
|
+
* atproto OAuth (confidential client) for standard.site publishing - the only
|
|
3
3
|
* module that touches @atproto/oauth-client-node. The blog itself is the OAuth
|
|
4
4
|
* client: its metadata + JWKS are static files on the site (client-metadata.ts),
|
|
5
5
|
* the private key and session live in the Secrets Manager secret (secret.ts),
|
|
@@ -14,11 +14,11 @@ export declare function generateClientKey(kid: string): Promise<Jwk>;
|
|
|
14
14
|
export declare function publicClientJwk(clientKey: Jwk): Promise<Record<string, unknown>>;
|
|
15
15
|
/**
|
|
16
16
|
* Check that the deployed /oauth/ documents match what this CLI would send the
|
|
17
|
-
* authorization server. Run before login/init
|
|
17
|
+
* authorization server. Run before login/init - a stale or missing deployment
|
|
18
18
|
* would otherwise fail deep inside the OAuth flow.
|
|
19
19
|
*/
|
|
20
20
|
export declare function verifyClientAssets(ctx: PdsContext, fetchImpl?: typeof fetch): Promise<void>;
|
|
21
|
-
/** The authorize/callback surface of NodeOAuthClient
|
|
21
|
+
/** The authorize/callback surface of NodeOAuthClient - injectable for tests. */
|
|
22
22
|
interface OauthFlow {
|
|
23
23
|
authorize(input: string): Promise<URL>;
|
|
24
24
|
callback(params: URLSearchParams): Promise<{
|
|
@@ -40,7 +40,7 @@ export interface LoginDeps {
|
|
|
40
40
|
*/
|
|
41
41
|
export declare function login(ctx: PdsContext, identifier: string, deps: LoginDeps): Promise<string>;
|
|
42
42
|
/**
|
|
43
|
-
* Restore the stored OAuth session (transparently refreshing
|
|
43
|
+
* Restore the stored OAuth session (transparently refreshing - the rotated
|
|
44
44
|
* refresh token lands back in the secret) and wrap it as a PdsClient. The
|
|
45
45
|
* session's fetchHandler adds DPoP + auth headers and handles nonce retries.
|
|
46
46
|
*/
|
package/dist/oauth.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* atproto OAuth (confidential client) for standard.site publishing
|
|
2
|
+
* atproto OAuth (confidential client) for standard.site publishing - the only
|
|
3
3
|
* module that touches @atproto/oauth-client-node. The blog itself is the OAuth
|
|
4
4
|
* client: its metadata + JWKS are static files on the site (client-metadata.ts),
|
|
5
5
|
* the private key and session live in the Secrets Manager secret (secret.ts),
|
|
@@ -18,7 +18,7 @@ function requireDomain(ctx) {
|
|
|
18
18
|
}
|
|
19
19
|
function requireClientKey(secret, pds) {
|
|
20
20
|
if (!secret.clientKey) {
|
|
21
|
-
throw new Error(`secret "${pds.secretName}" has no OAuth client key
|
|
21
|
+
throw new Error(`secret "${pds.secretName}" has no OAuth client key - run \`blogwright pds keygen\``);
|
|
22
22
|
}
|
|
23
23
|
return secret.clientKey;
|
|
24
24
|
}
|
|
@@ -43,7 +43,7 @@ async function buildClient(ctx, clientKey) {
|
|
|
43
43
|
// state, and syncs can run concurrently (overlapping CI deploys). Providing
|
|
44
44
|
// a lock tells the library it holds the only instance, which disables its
|
|
45
45
|
// invalid_grant recovery (re-read the store and adopt a concurrently
|
|
46
|
-
// rotated session) and instead deletes the session
|
|
46
|
+
// rotated session) and instead deletes the session - destroying the valid
|
|
47
47
|
// token a parallel sync just persisted. A process-local lock cannot
|
|
48
48
|
// serialize cross-process refreshes, so lock-less with recovery is correct.
|
|
49
49
|
});
|
|
@@ -78,12 +78,14 @@ export async function publicClientJwk(clientKey) {
|
|
|
78
78
|
// Round-trip through JSON: drops `d: undefined` left by the getter and
|
|
79
79
|
// matches exactly what the committed jwks.json file will contain.
|
|
80
80
|
const plain = JSON.parse(JSON.stringify(source));
|
|
81
|
+
// `d` is the private key component: it must be absent from the key set, not present-and-undefined.
|
|
82
|
+
// eslint-disable-next-line e18e/no-delete-property
|
|
81
83
|
delete plain.d;
|
|
82
84
|
return plain;
|
|
83
85
|
}
|
|
84
86
|
/**
|
|
85
87
|
* Check that the deployed /oauth/ documents match what this CLI would send the
|
|
86
|
-
* authorization server. Run before login/init
|
|
88
|
+
* authorization server. Run before login/init - a stale or missing deployment
|
|
87
89
|
* would otherwise fail deep inside the OAuth flow.
|
|
88
90
|
*/
|
|
89
91
|
export async function verifyClientAssets(ctx, fetchImpl = fetch) {
|
|
@@ -98,17 +100,17 @@ export async function verifyClientAssets(ctx, fetchImpl = fetch) {
|
|
|
98
100
|
for (const [url, expected] of expectations) {
|
|
99
101
|
const res = await fetchImpl(url);
|
|
100
102
|
if (!res.ok) {
|
|
101
|
-
throw new Error(`${url} is not deployed (HTTP ${res.status})
|
|
103
|
+
throw new Error(`${url} is not deployed (HTTP ${res.status}) - commit public/oauth/* and release first`);
|
|
102
104
|
}
|
|
103
105
|
let deployed;
|
|
104
106
|
try {
|
|
105
107
|
deployed = await res.json();
|
|
106
108
|
}
|
|
107
109
|
catch {
|
|
108
|
-
throw new Error(`${url} is not valid JSON
|
|
110
|
+
throw new Error(`${url} is not valid JSON - re-run \`blogwright pds keygen\` and redeploy`);
|
|
109
111
|
}
|
|
110
112
|
if (!deepEqual(deployed, expected)) {
|
|
111
|
-
throw new Error(`${url} does not match the local client configuration
|
|
113
|
+
throw new Error(`${url} does not match the local client configuration - ` +
|
|
112
114
|
're-run `blogwright pds keygen`, commit public/oauth/*, and release before logging in');
|
|
113
115
|
}
|
|
114
116
|
}
|
|
@@ -134,10 +136,10 @@ export async function login(ctx, identifier, deps) {
|
|
|
134
136
|
params = new URL(pasted.trim()).searchParams;
|
|
135
137
|
}
|
|
136
138
|
catch {
|
|
137
|
-
throw new Error('that was not a URL
|
|
139
|
+
throw new Error('that was not a URL - paste the full callback address, query string and all');
|
|
138
140
|
}
|
|
139
141
|
if (params.get('error')) {
|
|
140
|
-
throw new Error(`authorization failed: ${params.get('error')}
|
|
142
|
+
throw new Error(`authorization failed: ${params.get('error')} - ${params.get('error_description') ?? ''}`);
|
|
141
143
|
}
|
|
142
144
|
const { session } = await flow.callback(params);
|
|
143
145
|
ctx.logger.ok(`logged in as ${session.did}`);
|
|
@@ -149,7 +151,7 @@ function sessionExpired(err) {
|
|
|
149
151
|
return err instanceof OAuthResponseError && err.error === 'invalid_grant';
|
|
150
152
|
}
|
|
151
153
|
/**
|
|
152
|
-
* Restore the stored OAuth session (transparently refreshing
|
|
154
|
+
* Restore the stored OAuth session (transparently refreshing - the rotated
|
|
153
155
|
* refresh token lands back in the secret) and wrap it as a PdsClient. The
|
|
154
156
|
* session's fetchHandler adds DPoP + auth headers and handles nonce retries.
|
|
155
157
|
*/
|
|
@@ -158,7 +160,7 @@ export async function openPdsRepo(ctx) {
|
|
|
158
160
|
const secret = await loadPdsSecret(ctx);
|
|
159
161
|
const clientKey = requireClientKey(secret, pds);
|
|
160
162
|
if (!secret.did || !secret.session) {
|
|
161
|
-
throw new Error(`secret "${pds.secretName}" has no OAuth session
|
|
163
|
+
throw new Error(`secret "${pds.secretName}" has no OAuth session - run \`blogwright pds login\``);
|
|
162
164
|
}
|
|
163
165
|
const client = await buildClient(ctx, clientKey);
|
|
164
166
|
try {
|
|
@@ -168,9 +170,8 @@ export async function openPdsRepo(ctx) {
|
|
|
168
170
|
catch (err) {
|
|
169
171
|
if (sessionExpired(err)) {
|
|
170
172
|
throw new Error('the stored OAuth session is no longer valid (refresh tokens expire after 180 idle ' +
|
|
171
|
-
'days, and rotation races invalidate them)
|
|
173
|
+
'days, and rotation races invalidate them) - re-run `blogwright pds login`', { cause: err });
|
|
172
174
|
}
|
|
173
175
|
throw err;
|
|
174
176
|
}
|
|
175
177
|
}
|
|
176
|
-
//# sourceMappingURL=oauth.js.map
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SPI declaration this package default-exports: the `pds` namespace, the
|
|
3
|
+
* six actions `blogwright pds <action>` has always answered, the `pds` config
|
|
4
|
+
* key with this package's own validator, and the one resource node
|
|
5
|
+
* (`nodes.ts`) the plugin contributes to the graph engine.
|
|
6
|
+
*
|
|
7
|
+
* Every command here is a WRAPPER and nothing more. It parses its own flags
|
|
8
|
+
* out of the token array the host hands it and calls the matching function in
|
|
9
|
+
* `commands.ts`, whose signature is unchanged and which stays individually
|
|
10
|
+
* exported - `deploy` still calls `syncAfterDeploy(ctx)` with a plain
|
|
11
|
+
* `OpsContext` and never goes through this table.
|
|
12
|
+
*
|
|
13
|
+
* The context boundary is a plain assignment, not a conversion: every command
|
|
14
|
+
* function takes `PdsContext`, which `context.ts` declares as a `Pick` over
|
|
15
|
+
* core's `PluginContext<PdsConfig>`, so handing this module's `ctx` straight
|
|
16
|
+
* to `keygen`/`login`/… is an ordinary argument assignment the compiler
|
|
17
|
+
* checks. `nodes` is the exception, and deliberately so: it takes the FULL
|
|
18
|
+
* `PluginContext<PdsConfig>`, because its node reads `siteState`, `names`,
|
|
19
|
+
* `accountId` and `record()` - members no command context carries, and which
|
|
20
|
+
* only a lifecycle verb's dispatch boundary builds.
|
|
21
|
+
*
|
|
22
|
+
* `bootstrap`, `status` and `destroy` are absent on purpose. They are the
|
|
23
|
+
* host's generic lifecycle verbs over `nodes`, and a plugin declaring
|
|
24
|
+
* `bootstrap` or `destroy` is rejected at load
|
|
25
|
+
* (`packages/cli/src/plugins.ts`); `status` would merely shadow the generic
|
|
26
|
+
* one. So is `init` as a CONFIG CONTRIBUTOR (`Plugin.init`): `pds init` means
|
|
27
|
+
* the publication-setup command below, and declaring a contributor would let
|
|
28
|
+
* the host's generic `blogwright <plugin> init` shadow it.
|
|
29
|
+
*/
|
|
30
|
+
import type { PdsConfig, Plugin } from 'blogwright-core';
|
|
31
|
+
import { init, keygen, login, secretDelete, secretStatus, sync } from './commands.js';
|
|
32
|
+
/**
|
|
33
|
+
* The command functions the six declared actions wrap. Defaulted to the real
|
|
34
|
+
* ones ({@link pdsCommandFunctions}) and injectable through
|
|
35
|
+
* {@link createPdsPlugin}, which is the same default-parameter seam
|
|
36
|
+
* `commands.ts` already uses for `generateClientKey`, `openPdsRepo` and
|
|
37
|
+
* `oauthLogin`: the alternative for a test is patching the module, which
|
|
38
|
+
* DEVELOPMENT.md §Hexagonal architecture bans, and no port exists that would
|
|
39
|
+
* reveal what `login` was handed - it fetches the deployed `/oauth/`
|
|
40
|
+
* documents through the global `fetch` before the identifier is ever used.
|
|
41
|
+
* Nothing in production ever supplies this.
|
|
42
|
+
*/
|
|
43
|
+
export interface PdsCommandFunctions {
|
|
44
|
+
keygen: typeof keygen;
|
|
45
|
+
login: typeof login;
|
|
46
|
+
init: typeof init;
|
|
47
|
+
sync: typeof sync;
|
|
48
|
+
secretStatus: typeof secretStatus;
|
|
49
|
+
secretDelete: typeof secretDelete;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Build the plugin over a given set of command functions. Exported for tests
|
|
53
|
+
* only - see {@link PdsCommandFunctions}; every consumer uses the package's
|
|
54
|
+
* default export, which is {@link createPdsPlugin} called with the real ones.
|
|
55
|
+
*
|
|
56
|
+
* `validateConfig` and `nodes` are the imported functions THEMSELVES, not
|
|
57
|
+
* wrappers around them: this package owns `validatePdsConfig` and
|
|
58
|
+
* `buildPdsNodes` outright, and a wrapper is the one place a filter or a
|
|
59
|
+
* re-ordering could later appear between the host and what those two return.
|
|
60
|
+
*/
|
|
61
|
+
export declare function createPdsPlugin(fns?: PdsCommandFunctions): Plugin<PdsConfig>;
|
|
62
|
+
/**
|
|
63
|
+
* The package's default export, and since task 29 the LIVE path for
|
|
64
|
+
* `blogwright pds <action>`: task 26 added the `blogwright.plugin` manifest
|
|
65
|
+
* field to this package's `package.json`, which is what makes discovery
|
|
66
|
+
* import it at all, and task 29 deleted the CLI's `runPds` branch that used
|
|
67
|
+
* to answer the namespace ahead of plugin dispatch. Remove that manifest
|
|
68
|
+
* field and the namespace does not fall back to anything - it stops
|
|
69
|
+
* existing.
|
|
70
|
+
*/
|
|
71
|
+
declare const pdsPlugin: Plugin<PdsConfig>;
|
|
72
|
+
export default pdsPlugin;
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SPI declaration this package default-exports: the `pds` namespace, the
|
|
3
|
+
* six actions `blogwright pds <action>` has always answered, the `pds` config
|
|
4
|
+
* key with this package's own validator, and the one resource node
|
|
5
|
+
* (`nodes.ts`) the plugin contributes to the graph engine.
|
|
6
|
+
*
|
|
7
|
+
* Every command here is a WRAPPER and nothing more. It parses its own flags
|
|
8
|
+
* out of the token array the host hands it and calls the matching function in
|
|
9
|
+
* `commands.ts`, whose signature is unchanged and which stays individually
|
|
10
|
+
* exported - `deploy` still calls `syncAfterDeploy(ctx)` with a plain
|
|
11
|
+
* `OpsContext` and never goes through this table.
|
|
12
|
+
*
|
|
13
|
+
* The context boundary is a plain assignment, not a conversion: every command
|
|
14
|
+
* function takes `PdsContext`, which `context.ts` declares as a `Pick` over
|
|
15
|
+
* core's `PluginContext<PdsConfig>`, so handing this module's `ctx` straight
|
|
16
|
+
* to `keygen`/`login`/… is an ordinary argument assignment the compiler
|
|
17
|
+
* checks. `nodes` is the exception, and deliberately so: it takes the FULL
|
|
18
|
+
* `PluginContext<PdsConfig>`, because its node reads `siteState`, `names`,
|
|
19
|
+
* `accountId` and `record()` - members no command context carries, and which
|
|
20
|
+
* only a lifecycle verb's dispatch boundary builds.
|
|
21
|
+
*
|
|
22
|
+
* `bootstrap`, `status` and `destroy` are absent on purpose. They are the
|
|
23
|
+
* host's generic lifecycle verbs over `nodes`, and a plugin declaring
|
|
24
|
+
* `bootstrap` or `destroy` is rejected at load
|
|
25
|
+
* (`packages/cli/src/plugins.ts`); `status` would merely shadow the generic
|
|
26
|
+
* one. So is `init` as a CONFIG CONTRIBUTOR (`Plugin.init`): `pds init` means
|
|
27
|
+
* the publication-setup command below, and declaring a contributor would let
|
|
28
|
+
* the host's generic `blogwright <plugin> init` shadow it.
|
|
29
|
+
*/
|
|
30
|
+
import { init, keygen, login, secretDelete, secretStatus, sync } from './commands.js';
|
|
31
|
+
import { validatePdsConfig } from './config.js';
|
|
32
|
+
import { buildPdsNodes } from './nodes.js';
|
|
33
|
+
/**
|
|
34
|
+
* The CLI namespace this plugin claims. Kept distinct from
|
|
35
|
+
* {@link PDS_CONFIG_KEY} below even though the two strings coincide today: a
|
|
36
|
+
* namespace is a dispatch label the host may have to disambiguate against
|
|
37
|
+
* another plugin's, while the config key names a block in an operator's
|
|
38
|
+
* committed config file that nothing may rename underneath them.
|
|
39
|
+
*/
|
|
40
|
+
const PDS_NAMESPACE = 'pds';
|
|
41
|
+
/** The single top-level config key this package owns end to end. */
|
|
42
|
+
const PDS_CONFIG_KEY = 'pds';
|
|
43
|
+
/** One line for `blogwright --help`, distilled from the CLI's own `pds` usage block. */
|
|
44
|
+
const PDS_DESCRIPTION = 'standard.site (AT Protocol) publishing: OAuth client, publication setup and record sync';
|
|
45
|
+
/** The flag `pds login` reads the account to authorize from. */
|
|
46
|
+
const IDENTIFIER_FLAG = '--identifier';
|
|
47
|
+
/** The confirmation flag `pds secret delete` refuses to run without. */
|
|
48
|
+
const YES_FLAG = '--yes';
|
|
49
|
+
/** The real command functions - what the package's default export is built over. */
|
|
50
|
+
const pdsCommandFunctions = {
|
|
51
|
+
keygen,
|
|
52
|
+
login,
|
|
53
|
+
init,
|
|
54
|
+
sync,
|
|
55
|
+
secretStatus,
|
|
56
|
+
secretDelete,
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* The value of a `--flag value` pair in the tokens the host supplies, or
|
|
60
|
+
* `undefined` when the flag is absent - which is what makes the wrapped
|
|
61
|
+
* function's own refusal (`pds login requires --identifier <handle-or-did>`)
|
|
62
|
+
* the single place that message lives.
|
|
63
|
+
*
|
|
64
|
+
* The next token is taken verbatim, with no "does it look like a flag?"
|
|
65
|
+
* guard, because the host cannot produce a dangling flag: `serialiseFlags`
|
|
66
|
+
* (`packages/cli/src/plugin-commands.ts`) emits a string flag only as the
|
|
67
|
+
* pair `--name value`, and `parseArgs` has already consumed every `--flag` out
|
|
68
|
+
* of the positionals before the remainder reaches a plugin.
|
|
69
|
+
*/
|
|
70
|
+
function flagValue(args, flag) {
|
|
71
|
+
const index = args.indexOf(flag);
|
|
72
|
+
if (index === -1)
|
|
73
|
+
return undefined;
|
|
74
|
+
return args[index + 1];
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The six actions, with the exact action strings the CLI's deleted `pds`
|
|
78
|
+
* dispatch used to accept - `secret status` and `secret delete` declared as
|
|
79
|
+
* the two-word actions they are. Since task 29 these declarations are the
|
|
80
|
+
* only definition of the namespace's actions: the host matches them
|
|
81
|
+
* longest-first, in place of the positional shifting `runPds` hand-rolled.
|
|
82
|
+
* Renaming or dropping one here removes the action from the CLI.
|
|
83
|
+
*/
|
|
84
|
+
function pdsCommands(fns) {
|
|
85
|
+
return [
|
|
86
|
+
{
|
|
87
|
+
action: 'keygen',
|
|
88
|
+
summary: 'generate the OAuth client key: private JWK into Secrets Manager, public documents into public/oauth/',
|
|
89
|
+
async run(ctx) {
|
|
90
|
+
await fns.keygen(ctx);
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
action: 'login',
|
|
95
|
+
summary: `interactive OAuth bootstrap, storing the session in Secrets Manager (${IDENTIFIER_FLAG} <handle-or-did>)`,
|
|
96
|
+
async run(ctx, args) {
|
|
97
|
+
await fns.login(ctx, { identifier: flagValue(args, IDENTIFIER_FLAG) });
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
action: 'init',
|
|
102
|
+
summary: 'create or update the standard.site publication record and write the site verification files',
|
|
103
|
+
async run(ctx) {
|
|
104
|
+
await fns.init(ctx);
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
action: 'sync',
|
|
109
|
+
summary: 'reconcile site.standard.document records with the content collection (production only)',
|
|
110
|
+
async run(ctx) {
|
|
111
|
+
await fns.sync(ctx);
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
action: 'secret status',
|
|
116
|
+
summary: 'show the secret metadata - never the value',
|
|
117
|
+
async run(ctx) {
|
|
118
|
+
await fns.secretStatus(ctx);
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
action: 'secret delete',
|
|
123
|
+
summary: `delete the secret, logging out and discarding the key (${YES_FLAG})`,
|
|
124
|
+
async run(ctx, args) {
|
|
125
|
+
await fns.secretDelete(ctx, { yes: args.includes(YES_FLAG) });
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Build the plugin over a given set of command functions. Exported for tests
|
|
132
|
+
* only - see {@link PdsCommandFunctions}; every consumer uses the package's
|
|
133
|
+
* default export, which is {@link createPdsPlugin} called with the real ones.
|
|
134
|
+
*
|
|
135
|
+
* `validateConfig` and `nodes` are the imported functions THEMSELVES, not
|
|
136
|
+
* wrappers around them: this package owns `validatePdsConfig` and
|
|
137
|
+
* `buildPdsNodes` outright, and a wrapper is the one place a filter or a
|
|
138
|
+
* re-ordering could later appear between the host and what those two return.
|
|
139
|
+
*/
|
|
140
|
+
export function createPdsPlugin(fns = pdsCommandFunctions) {
|
|
141
|
+
return {
|
|
142
|
+
name: PDS_NAMESPACE,
|
|
143
|
+
description: PDS_DESCRIPTION,
|
|
144
|
+
commands: pdsCommands(fns),
|
|
145
|
+
configKey: PDS_CONFIG_KEY,
|
|
146
|
+
validateConfig: validatePdsConfig,
|
|
147
|
+
nodes: buildPdsNodes,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The package's default export, and since task 29 the LIVE path for
|
|
152
|
+
* `blogwright pds <action>`: task 26 added the `blogwright.plugin` manifest
|
|
153
|
+
* field to this package's `package.json`, which is what makes discovery
|
|
154
|
+
* import it at all, and task 29 deleted the CLI's `runPds` branch that used
|
|
155
|
+
* to answer the namespace ahead of plugin dispatch. Remove that manifest
|
|
156
|
+
* field and the namespace does not fall back to anything - it stops
|
|
157
|
+
* existing.
|
|
158
|
+
*/
|
|
159
|
+
const pdsPlugin = createPdsPlugin();
|
|
160
|
+
export default pdsPlugin;
|
package/dist/rkey.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ export declare const tidFromPath: (path: string) => string;
|
|
|
15
15
|
/** Exported only for tests. */
|
|
16
16
|
export declare const extractDate: (path: string) => string | undefined;
|
|
17
17
|
/**
|
|
18
|
-
* Canonical URL path for a blog post
|
|
18
|
+
* Canonical URL path for a blog post - the shape `src/pages/posts/index.astro` links
|
|
19
19
|
* (trailing slash). Rkeys derive from this string, so slugs must never change after
|
|
20
20
|
* publication.
|
|
21
21
|
*/
|
package/dist/rkey.js
CHANGED
|
@@ -25,7 +25,7 @@ export const tidFromPath = (path) => {
|
|
|
25
25
|
if (Number.isNaN(date.getTime())) {
|
|
26
26
|
// A date-like digit pattern that is not a real date (e.g. a product
|
|
27
27
|
// slug "sku-3456-78-90"). The reference implementation throws here, so
|
|
28
|
-
// no existing record can hold a date-derived TID for such a path
|
|
28
|
+
// no existing record can hold a date-derived TID for such a path -
|
|
29
29
|
// falling back to the whole-path hash cannot diverge from live rkeys.
|
|
30
30
|
tid = hashBits(path, 63);
|
|
31
31
|
}
|
|
@@ -69,11 +69,10 @@ const encodeTid = (n) => {
|
|
|
69
69
|
const BASE32 = '234567abcdefghijklmnopqrstuvwxyz';
|
|
70
70
|
// --- example-specific helpers (not vendored) ---
|
|
71
71
|
/**
|
|
72
|
-
* Canonical URL path for a blog post
|
|
72
|
+
* Canonical URL path for a blog post - the shape `src/pages/posts/index.astro` links
|
|
73
73
|
* (trailing slash). Rkeys derive from this string, so slugs must never change after
|
|
74
74
|
* publication.
|
|
75
75
|
*/
|
|
76
76
|
export const postPath = (slug) => `/posts/${slug}/`;
|
|
77
77
|
/** AT-URI of the standard.site document record for a post. */
|
|
78
78
|
export const documentUri = (did, slug) => `at://${did}/site.standard.document/${tidFromPath(postPath(slug))}`;
|
|
79
|
-
//# sourceMappingURL=rkey.js.map
|
package/dist/secret.d.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { Jwk, NodeSavedSession, NodeSavedSessionStore } from '@atproto/oauth-client-node';
|
|
10
10
|
import type { SecretsManagerClient } from 'blogwright-core';
|
|
11
11
|
import type { PdsContext } from './context.js';
|
|
12
|
-
/** The client surface secret persistence needs
|
|
12
|
+
/** The client surface secret persistence needs - structural, so tests can stub it. */
|
|
13
13
|
export type SecretsStore = Pick<SecretsManagerClient, 'getSecretValue' | 'upsertSecret'>;
|
|
14
14
|
export interface PdsSecret {
|
|
15
15
|
version: 1;
|
|
@@ -23,13 +23,13 @@ export interface PdsSecret {
|
|
|
23
23
|
/** Parse the secret JSON, rejecting the pre-OAuth app-password shape. */
|
|
24
24
|
export declare function parsePdsSecret(raw: string, secretName: string): PdsSecret;
|
|
25
25
|
/**
|
|
26
|
-
* Fetch and parse the secret. Called only at keygen/login/sync time
|
|
26
|
+
* Fetch and parse the secret. Called only at keygen/login/sync time -
|
|
27
27
|
* secret material must never be loaded during context creation.
|
|
28
28
|
*/
|
|
29
29
|
export declare function loadPdsSecret(ctx: PdsContext): Promise<PdsSecret>;
|
|
30
30
|
/**
|
|
31
31
|
* Read-modify-write the secret; starts from an empty v1 value when absent.
|
|
32
|
-
* `replaceLegacy` lets keygen start over from a pre-OAuth app-password value
|
|
32
|
+
* `replaceLegacy` lets keygen start over from a pre-OAuth app-password value -
|
|
33
33
|
* the migration entry point; every other writer must reject it.
|
|
34
34
|
*/
|
|
35
35
|
export declare function updatePdsSecret(secrets: SecretsStore, secretName: string, mutate: (secret: PdsSecret) => PdsSecret, opts?: {
|
|
@@ -38,7 +38,7 @@ export declare function updatePdsSecret(secrets: SecretsStore, secretName: strin
|
|
|
38
38
|
}): Promise<PdsSecret>;
|
|
39
39
|
/**
|
|
40
40
|
* NodeOAuthClient session store backed by the secret. `set` runs on login and
|
|
41
|
-
* on every refresh-token rotation
|
|
41
|
+
* on every refresh-token rotation - persisting it is what keeps the CI session
|
|
42
42
|
* alive; `del` clears only the session (client key and DID survive a logout).
|
|
43
43
|
*/
|
|
44
44
|
export declare function sessionStoreForSecret(secrets: SecretsStore, secretName: string): NodeSavedSessionStore;
|
package/dist/secret.js
CHANGED
|
@@ -29,7 +29,7 @@ function isLegacySecret(parsed) {
|
|
|
29
29
|
export function parsePdsSecret(raw, secretName) {
|
|
30
30
|
const parsed = parseJsonObject(raw, secretName);
|
|
31
31
|
if (isLegacySecret(parsed)) {
|
|
32
|
-
throw new Error(`secret "${secretName}" holds app-password credentials
|
|
32
|
+
throw new Error(`secret "${secretName}" holds app-password credentials - app passwords are no longer ` +
|
|
33
33
|
'supported; run `blogwright pds keygen` then `blogwright pds login`');
|
|
34
34
|
}
|
|
35
35
|
if (parsed.version !== 1) {
|
|
@@ -38,20 +38,20 @@ export function parsePdsSecret(raw, secretName) {
|
|
|
38
38
|
return parsed;
|
|
39
39
|
}
|
|
40
40
|
/**
|
|
41
|
-
* Fetch and parse the secret. Called only at keygen/login/sync time
|
|
41
|
+
* Fetch and parse the secret. Called only at keygen/login/sync time -
|
|
42
42
|
* secret material must never be loaded during context creation.
|
|
43
43
|
*/
|
|
44
44
|
export async function loadPdsSecret(ctx) {
|
|
45
45
|
const pds = requirePdsConfig(ctx);
|
|
46
46
|
const raw = await ctx.clients.secrets.getSecretValue(pds.secretName);
|
|
47
47
|
if (!raw) {
|
|
48
|
-
throw new Error(`no secret at "${pds.secretName}"
|
|
48
|
+
throw new Error(`no secret at "${pds.secretName}" - create it with \`blogwright pds keygen\``);
|
|
49
49
|
}
|
|
50
50
|
return parsePdsSecret(raw, pds.secretName);
|
|
51
51
|
}
|
|
52
52
|
/**
|
|
53
53
|
* Read-modify-write the secret; starts from an empty v1 value when absent.
|
|
54
|
-
* `replaceLegacy` lets keygen start over from a pre-OAuth app-password value
|
|
54
|
+
* `replaceLegacy` lets keygen start over from a pre-OAuth app-password value -
|
|
55
55
|
* the migration entry point; every other writer must reject it.
|
|
56
56
|
*/
|
|
57
57
|
export async function updatePdsSecret(secrets, secretName, mutate, opts = {}) {
|
|
@@ -66,7 +66,7 @@ export async function updatePdsSecret(secrets, secretName, mutate, opts = {}) {
|
|
|
66
66
|
}
|
|
67
67
|
/**
|
|
68
68
|
* NodeOAuthClient session store backed by the secret. `set` runs on login and
|
|
69
|
-
* on every refresh-token rotation
|
|
69
|
+
* on every refresh-token rotation - persisting it is what keeps the CI session
|
|
70
70
|
* alive; `del` clears only the session (client key and DID survive a logout).
|
|
71
71
|
*/
|
|
72
72
|
export function sessionStoreForSecret(secrets, secretName) {
|
|
@@ -83,7 +83,7 @@ export function sessionStoreForSecret(secrets, secretName) {
|
|
|
83
83
|
async set(sub, session) {
|
|
84
84
|
// Retried: this write persists a just-rotated single-use refresh token.
|
|
85
85
|
// If it throws, the OAuth library revokes the new token and deletes the
|
|
86
|
-
// session
|
|
86
|
+
// session - one Secrets Manager throttle at exactly the wrong moment
|
|
87
87
|
// would otherwise force an interactive re-login.
|
|
88
88
|
let lastError;
|
|
89
89
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
@@ -107,4 +107,3 @@ export function sessionStoreForSecret(secrets, secretName) {
|
|
|
107
107
|
},
|
|
108
108
|
};
|
|
109
109
|
}
|
|
110
|
-
//# sourceMappingURL=secret.js.map
|