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/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 — the only
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 — a stale or missing deployment
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 — injectable for tests. */
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 — the rotated
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 — the only
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 — run \`blogwright pds keygen\``);
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 — destroying the valid
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 — a stale or missing deployment
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}) — commit public/oauth/* and release first`);
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 — re-run \`blogwright pds keygen\` and redeploy`);
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 — paste the full callback address, query string and all');
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')} — ${params.get('error_description') ?? ''}`);
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 — the rotated
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 — run \`blogwright pds login\``);
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) — re-run `blogwright pds login`', { cause: err });
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
@@ -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 — the shape `src/pages/posts/index.astro` links
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 — the shape `src/pages/posts/index.astro` links
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 — structural, so tests can stub it. */
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 — persisting it is what keeps the CI session
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 — app passwords are no longer ` +
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}" — create it with \`blogwright pds keygen\``);
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 — persisting it is what keeps the CI session
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 — one Secrets Manager throttle at exactly the wrong moment
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