synomem 0.6.0 → 0.6.2

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/src/cli.ts CHANGED
@@ -21,14 +21,19 @@ import {
21
21
  type ConfigPlan,
22
22
  type CredentialStoreChoice,
23
23
  } from './configure.js';
24
- import { discoverBoundWorkspace, discoverOrganizations, workspaceChoices } from './discover.js';
24
+ import {
25
+ discoverAccessKeyWorkspaces,
26
+ discoverOrganizations,
27
+ workspaceChoices,
28
+ type DiscoveredWorkspace,
29
+ } from './discover.js';
25
30
  import { DEFAULT_WORKSPACE, listLocalWorkspaces, localWorkspaceHome } from './workspaces.js';
26
31
  import {
27
32
  findProjectSelection,
28
33
  resolveWorkspaceSelection,
29
34
  writeProjectSelection,
30
35
  } from './project.js';
31
- import { defaultPromptIo, type PromptIo } from './prompt.js';
36
+ import { defaultPromptIo, select, type PromptIo } from './prompt.js';
32
37
  import { credentialReference, OsCredentialStore, type CredentialStore } from './credentials.js';
33
38
  import { asSynomemError, SynomemError, type SynomemErrorCode } from './errors.js';
34
39
  import { atomicWriteFile } from './fs-utils.js';
@@ -84,12 +89,12 @@ export interface CliDependencies {
84
89
  }) => Promise<void>;
85
90
  /*
86
91
  * Injected so setup can be tested without a network. The default asks the
87
- * service which workspace an access key is bound to.
92
+ * service which workspaces an access key can reach.
88
93
  */
89
- discoverBoundWorkspace?: (options: {
94
+ discoverAccessKeyWorkspaces?: (options: {
90
95
  baseUrl: string;
91
96
  accessToken: string;
92
- }) => Promise<{ workspaceId: string }>;
97
+ }) => Promise<{ organizationId: string; workspaces: DiscoveredWorkspace[] }>;
93
98
  createImportBundle?: (home: string) => Promise<ImportBundle>;
94
99
  remoteImport?: (options: {
95
100
  baseUrl: string;
@@ -419,7 +424,8 @@ export function createCli(
419
424
 
420
425
  const credentialStore = dependencies.credentialStore ?? new OsCredentialStore();
421
426
  const oauthLogin = dependencies.oauthLogin ?? loginWithOAuth;
422
- const discoverWorkspace = dependencies.discoverBoundWorkspace ?? discoverBoundWorkspace;
427
+ const discoverWorkspaces =
428
+ dependencies.discoverAccessKeyWorkspaces ?? discoverAccessKeyWorkspaces;
423
429
  const promptIo = dependencies.promptIo ?? defaultPromptIo();
424
430
  const verifyRemoteCredential =
425
431
  dependencies.verifyRemoteCredential ??
@@ -565,12 +571,12 @@ export function createCli(
565
571
  const remoteCommand = program.command('remote').description('Administer a remote workspace');
566
572
 
567
573
  /*
568
- * The browser counterpart to an access key naming its own workspace.
569
- *
570
- * A signed-in account may reach several organizations, each with several
571
- * workspaces, so there is a genuine choice to make -- and no way to make it
572
- * without seeing the list. Printing the IDs alongside the names is the point:
573
- * the ID is what `backend use remote --workspace` takes.
574
+ * The browser counterpart to `discoverAccessKeyWorkspaces` same purpose,
575
+ * different credential: a signed-in account may reach several
576
+ * organizations, each with several workspaces, so there is a genuine choice
577
+ * to make, and no way to make it without seeing the list. Printing the IDs
578
+ * alongside the names is the point: the ID is what `backend use remote
579
+ * --workspace` takes.
574
580
  */
575
581
  remoteCommand
576
582
  .command('workspaces')
@@ -655,6 +661,8 @@ export function createCli(
655
661
  credentialReference(backend.baseUrl, backend.workspaceId, administrator),
656
662
  credentialStore,
657
663
  env,
664
+ fetch,
665
+ resolveHome(global.home),
658
666
  );
659
667
  const importer = new RemoteImportClient({
660
668
  baseUrl: backend.baseUrl,
@@ -723,18 +731,63 @@ export function createCli(
723
731
  const serviceUrl = plan.serviceUrl ?? cloudApiUrl(env);
724
732
 
725
733
  /*
726
- * The workspace is discovered, not typed.
727
- *
728
- * An installation access key is bound to exactly one workspace, so the
729
- * service can be asked which one rather than the person. An explicit
730
- * --workspace still wins, because automation should not depend on a
731
- * network round trip to configure a machine.
734
+ * A member-owned access key reaches every workspace in its organization,
735
+ * never just one, so the workspace is discovered and then chosen — not
736
+ * typed from memory, and not assumed. An explicit --workspace still wins
737
+ * regardless: automation should not depend on a network round trip, and a
738
+ * person who already knows which one they want should not be asked again.
732
739
  */
733
740
  let workspaceId = plan.workspaceId;
734
741
  if (plan.backend === 'remote' && !workspaceId && token) {
735
- const bound = await discoverWorkspace({ baseUrl: serviceUrl, accessToken: token });
736
- workspaceId = bound.workspaceId;
737
- io.stdout(`Access key is bound to workspace ${workspaceId}.\n`);
742
+ const discovered = await discoverWorkspaces({ baseUrl: serviceUrl, accessToken: token });
743
+ if (discovered.workspaces.length === 0) {
744
+ throw new SynomemError(
745
+ 'INVALID_INPUT',
746
+ [
747
+ "This access key's organization has no workspaces yet.",
748
+ 'Create one in the Synomem portal, then re-run this command — or pass',
749
+ '--workspace <workspace-id> once one exists.',
750
+ ].join('\n'),
751
+ );
752
+ } else if (discovered.workspaces.length === 1) {
753
+ workspaceId = discovered.workspaces[0]!.id;
754
+ /*
755
+ * The key itself reaches every workspace this organization has —
756
+ * there is just one to choose from today. Wording this as the key's
757
+ * own workspace ("this key's workspace") was a repeated, corrected
758
+ * mistake: it is this MACHINE's choice of which workspace to act in
759
+ * with the key, not a property of the key.
760
+ */
761
+ io.stdout(
762
+ `This machine will use ${discovered.workspaces[0]!.displayName} (${workspaceId}) — ` +
763
+ 'the only workspace this key currently reaches. Run `synomem backend use ' +
764
+ 'remote --workspace <workspace-id>` to point it at a different one later.\n',
765
+ );
766
+ } else if (promptIo.interactive) {
767
+ workspaceId = await select(
768
+ promptIo,
769
+ 'Which workspace should this machine use?',
770
+ discovered.workspaces.map((workspace) => ({
771
+ value: workspace.id,
772
+ label: workspace.displayName,
773
+ detail: workspace.id,
774
+ })),
775
+ );
776
+ } else {
777
+ throw new SynomemError(
778
+ 'INVALID_INPUT',
779
+ [
780
+ 'This access key can reach more than one workspace, so a non-interactive',
781
+ 'setup needs to be told which one:',
782
+ '',
783
+ ...discovered.workspaces.map(
784
+ (workspace) => ` ${workspace.id} ${workspace.displayName}`,
785
+ ),
786
+ '',
787
+ 'Re-run with --workspace <workspace-id>.',
788
+ ].join('\n'),
789
+ );
790
+ }
738
791
  }
739
792
  if (plan.backend === 'remote' && !workspaceId) {
740
793
  /*
@@ -846,7 +899,7 @@ export function createCli(
846
899
  .option('--credential-store <where>', 'auto, keychain, file, or environment', 'auto')
847
900
  // The token is read from stdin, never taken as an argument: an argument is
848
901
  // kept by the shell history and visible in the process list.
849
- .option('--access-token-stdin', 'read the installation access key from stdin', false)
902
+ .option('--access-token-stdin', 'read the access key from stdin', false)
850
903
  .option('--yes', 'apply without confirming', false)
851
904
  .action(
852
905
  async (
@@ -864,12 +917,12 @@ export function createCli(
864
917
  throw new SynomemError('INVALID_INPUT', 'Pass --backend local or --backend remote.');
865
918
  }
866
919
  const backend: BackendChoice = options.backend;
867
- // An access key names its own workspace, so --workspace is only
868
- // required when there is no key to ask.
920
+ // An access key can be asked which workspaces it reaches, so
921
+ // --workspace is only required when there is no key to ask.
869
922
  if (backend === 'remote' && !global.workspace && !options.accessTokenStdin) {
870
923
  throw new SynomemError(
871
924
  'INVALID_INPUT',
872
- 'Remote setup requires --workspace, or --access-token-stdin so the key can name its own.',
925
+ 'Remote setup requires --workspace, or --access-token-stdin so the key can be asked.',
873
926
  );
874
927
  }
875
928
  const token = options.accessTokenStdin ? await readAccessToken(promptIo) : undefined;
package/src/configure.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * that blocks forever on a pipe is worse than one that says which flags it
8
8
  * needs.
9
9
  */
10
- import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
10
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { cloudApiUrl } from './cloud.js';
13
13
  import { SynomemError } from './errors.js';
@@ -117,16 +117,39 @@ export function assertInteractive(io: PromptIo): void {
117
117
  * Separate from `config.json` so a configuration file can be read, copied or
118
118
  * pasted into an issue without carrying a secret with it.
119
119
  */
120
+ function credentialFilePath(home: string): string {
121
+ return join(home, 'credentials', 'installation.json');
122
+ }
123
+
120
124
  export function writeCredentialFile(home: string, token: string): string {
121
125
  const directory = join(home, 'credentials');
122
126
  mkdirSync(directory, { recursive: true, mode: 0o700 });
123
127
  chmodSync(directory, 0o700);
124
- const path = join(directory, 'installation.json');
128
+ const path = credentialFilePath(home);
125
129
  writeFileSync(path, `${JSON.stringify({ accessToken: token }, null, 2)}\n`, { mode: 0o600 });
126
130
  chmodSync(path, 0o600);
127
131
  return path;
128
132
  }
129
133
 
134
+ /**
135
+ * Reads back what `writeCredentialFile` wrote.
136
+ *
137
+ * Without this, choosing the restricted-file store at `synomem config` wrote
138
+ * a credential nothing ever read again: every later command still asked the
139
+ * OS keychain, found nothing there, and failed with AUTH_REQUIRED even though
140
+ * the key was sitting right there on disk.
141
+ */
142
+ export function readCredentialFile(home: string): string | undefined {
143
+ const path = credentialFilePath(home);
144
+ if (!existsSync(path)) return undefined;
145
+ try {
146
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as { accessToken?: unknown };
147
+ return typeof parsed.accessToken === 'string' ? parsed.accessToken : undefined;
148
+ } catch {
149
+ return undefined;
150
+ }
151
+ }
152
+
130
153
  /** A key's identifying prefix. Never the key. */
131
154
  export function credentialFingerprint(token: string): string {
132
155
  const head = token.slice(0, 12);
@@ -194,8 +217,8 @@ export async function runConfigWizard(
194
217
  },
195
218
  {
196
219
  value: 'access-key',
197
- label: 'Use an installation access key',
198
- detail: 'Create one at https://portal.synomem.ai/installations',
220
+ label: 'Use an access key',
221
+ detail: 'Create one at https://portal.synomem.ai/access-keys/new',
199
222
  },
200
223
  ]);
201
224
 
@@ -203,10 +226,11 @@ export async function runConfigWizard(
203
226
  * Deliberately no workspace prompt here.
204
227
  *
205
228
  * A hosted workspace ID looks like `ws-04psqx2rkt8ttft7a1t2z69r97`, and the
206
- * credential authorized in the next step already knows which workspace it
207
- * reaches -- an installation key is bound to exactly one, and a browser
208
- * sign-in can list the ones the account belongs to. Asking first means
209
- * asking a person to go and look something up that we are about to be told.
229
+ * credential authorized in the next step already knows the answer -- an
230
+ * access key can list every workspace its organization has, and a browser
231
+ * sign-in can list the ones the account belongs to across every
232
+ * organization. Asking first means asking a person to go and look something
233
+ * up that we are about to be told.
210
234
  */
211
235
 
212
236
  const credentialStore =
@@ -223,7 +247,7 @@ export async function runConfigWizard(
223
247
 
224
248
  /** Reads an access key without ever accepting it as an argument. */
225
249
  export async function readAccessToken(io: PromptIo): Promise<string> {
226
- const token = await askSecret(io, 'Installation access key');
250
+ const token = await askSecret(io, 'Access key');
227
251
  if (!token) throw new SynomemError('INVALID_INPUT', 'No access key was provided.');
228
252
  return token;
229
253
  }
@@ -7,12 +7,19 @@ const serviceName = 'ai.synomem.credentials';
7
7
  const maximumOutputBytes = 128 * 1024;
8
8
 
9
9
  /**
10
- * An installation access key.
10
+ * A member-owned access key.
11
11
  *
12
- * Not an OAuth credential: it has no refresh, no token endpoint and no client,
13
- * and it authorizes a MACHINE rather than a person. Keeping it a distinct shape
14
- * stops code treating it as refreshable, which would mean silently failing to
15
- * renew something that never expires that way.
12
+ * Not an OAuth credential: it has no refresh, no token endpoint and no
13
+ * client, and it authorizes the MEMBER who created it, reaching every
14
+ * workspace their organization membership allows -- never a single machine
15
+ * or a single workspace. Keeping it a distinct shape stops code treating it
16
+ * as refreshable, which would mean silently failing to renew something that
17
+ * never expires that way.
18
+ *
19
+ * The `kind` value and this type's own name predate member-owned access
20
+ * keys; both still say "installation" because renaming either changes the
21
+ * shape already written to disk and the OS keychain on every machine that
22
+ * has run `synomem config`, for no behavioral gain.
16
23
  */
17
24
  export interface StoredInstallationKey {
18
25
  kind: 'installation-key';
package/src/discover.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /*
2
- * Finding out which workspace a credential belongs to, so nobody has to type
2
+ * Finding out which workspaces a credential can reach, so nobody has to type
3
3
  * one from memory.
4
4
  *
5
5
  * A hosted workspace ID looks like `ws-04psqx2rkt8ttft7a1t2z69r97`. Asking a
@@ -9,11 +9,12 @@
9
9
  *
10
10
  * Two credentials, two routes, because they carry different authority:
11
11
  *
12
- * An installation access key is bound to exactly one workspace. There is
13
- * nothing to choose, so `discoverBoundWorkspace` reads it off the data plane
14
- * and setup asks nothing at all.
12
+ * A member-owned access key authenticates the ACCOUNT that created it, and
13
+ * reaches every workspace in that account's organization -- never just one
14
+ * -- so `discoverAccessKeyWorkspaces` lists them off the data plane, with no
15
+ * workspace chosen yet, which is exactly what setup cannot supply up front.
15
16
  *
16
- * A browser sign-in authorizes an ACCOUNT, which may reach several
17
+ * A browser sign-in also authorizes an ACCOUNT, which may reach several
17
18
  * organizations, each with several workspaces. `discoverOrganizations` lists
18
19
  * them for selection.
19
20
  */
@@ -82,23 +83,21 @@ async function readJson<T>(options: DiscoveryOptions, path: string): Promise<T>
82
83
  }
83
84
 
84
85
  /**
85
- * The single workspace an installation access key can address.
86
+ * Every workspace a member-owned access key can reach, asked before any one
87
+ * of them has been chosen.
86
88
  *
87
- * Answered by the data plane rather than the control plane: an installation key
88
- * is not an account principal, so it cannot list organizations, but it can
89
- * always say where it is bound.
89
+ * Answered by the data plane rather than the control plane: an access key is
90
+ * not an account principal the control plane recognizes, but the data plane
91
+ * already knows which organization owns it and can list that organization's
92
+ * workspaces without requiring one to be named first.
90
93
  */
91
- export async function discoverBoundWorkspace(
94
+ export async function discoverAccessKeyWorkspaces(
92
95
  options: DiscoveryOptions,
93
- ): Promise<{ workspaceId: string; actor: { kind: string; id: string; displayName?: string } }> {
94
- const identity = await readJson<{
95
- workspaceId: string;
96
- actor: { kind: string; id: string; displayName?: string };
97
- }>(options, 'v1/identity');
98
- if (!identity.workspaceId) {
99
- throw new SynomemError('REMOTE_PROTOCOL', 'The service did not report a bound workspace.');
100
- }
101
- return { workspaceId: identity.workspaceId, actor: identity.actor };
96
+ ): Promise<{ organizationId: string; workspaces: DiscoveredWorkspace[] }> {
97
+ return await readJson<{ organizationId: string; workspaces: DiscoveredWorkspace[] }>(
98
+ options,
99
+ 'v1/access-keys/workspaces',
100
+ );
102
101
  }
103
102
 
104
103
  /**
package/src/index.ts CHANGED
@@ -58,7 +58,11 @@ export {
58
58
  writeProjectSelection,
59
59
  } from './project.js';
60
60
  export type { ProjectConfig, ProjectSelection } from './project.js';
61
- export { discoverBoundWorkspace, discoverOrganizations, workspaceChoices } from './discover.js';
61
+ export {
62
+ discoverAccessKeyWorkspaces,
63
+ discoverOrganizations,
64
+ workspaceChoices,
65
+ } from './discover.js';
62
66
  export type { DiscoveredOrganization, DiscoveredWorkspace, DiscoveryOptions } from './discover.js';
63
67
  export { asSynomemError, errorCodes, SynomemError } from './errors.js';
64
68
  export {
package/src/oauth.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  import { SynomemError } from './errors.js';
9
9
  import type { CredentialStore, StoredOAuthCredential } from './credentials.js';
10
10
  import type { SynomemCredentialProvider } from './remote.js';
11
+ import { readCredentialFile } from './configure.js';
11
12
 
12
13
  const defaultScope = 'synomem:read synomem:write offline_access';
13
14
 
@@ -195,6 +196,8 @@ export async function loginWithOAuth(options: OAuthLoginOptions): Promise<void>
195
196
  export class StoredCredentialProvider implements SynomemCredentialProvider {
196
197
  private refresh?: Promise<string | undefined>;
197
198
  private credential?: StoredOAuthCredential;
199
+ /** Set when the stored credential is an access key rather than an OAuth grant. */
200
+ private staticAccessToken?: string;
198
201
  private loaded = false;
199
202
 
200
203
  constructor(
@@ -202,6 +205,12 @@ export class StoredCredentialProvider implements SynomemCredentialProvider {
202
205
  private readonly store: CredentialStore,
203
206
  private readonly env: NodeJS.ProcessEnv = process.env,
204
207
  private readonly fetchImplementation: typeof fetch = fetch,
208
+ /**
209
+ * Where `synomem config`'s restricted-file credential store, if chosen,
210
+ * would have written an access key. Optional because local-only and
211
+ * environment-only callers have no such file to fall back to.
212
+ */
213
+ private readonly home?: string,
205
214
  ) {}
206
215
 
207
216
  async getAccessToken(signal?: AbortSignal): Promise<string | undefined> {
@@ -209,14 +218,20 @@ export class StoredCredentialProvider implements SynomemCredentialProvider {
209
218
  if (!this.loaded) {
210
219
  const stored = await this.store.get(this.reference);
211
220
  /*
212
- * An installation key is not an OAuth credential: it cannot be refreshed
213
- * and has no client or token endpoint. Treating one as OAuth would mean
214
- * trying to renew something that never renews that way, so this path
215
- * ignores it and lets the installation-key path handle it.
221
+ * An access key is not an OAuth credential: it cannot be refreshed and
222
+ * has no client or token endpoint. It is used exactly as stored,
223
+ * whichever of the two places it was found.
216
224
  */
217
- this.credential = stored && 'kind' in stored ? undefined : stored;
225
+ if (stored && 'kind' in stored) {
226
+ this.staticAccessToken = stored.accessToken;
227
+ } else if (stored) {
228
+ this.credential = stored;
229
+ } else if (this.home) {
230
+ this.staticAccessToken = readCredentialFile(this.home);
231
+ }
218
232
  this.loaded = true;
219
233
  }
234
+ if (this.staticAccessToken) return this.staticAccessToken;
220
235
  const credential = this.credential;
221
236
  if (!credential) return undefined;
222
237
  if (!credential.expiresAt || credential.expiresAt > Date.now()) return credential.accessToken;
package/src/remote.ts CHANGED
@@ -647,6 +647,15 @@ export class RemoteSynomemService implements SynomemService {
647
647
  headers: {
648
648
  accept: 'application/json',
649
649
  authorization: `Bearer ${accessToken}`,
650
+ /*
651
+ * Harmless for an OAuth actor token (its workspace and identity are
652
+ * already bound into the token itself), and required for an access
653
+ * key: an access key authenticates the member who owns it, not a
654
+ * machine or a workspace, so which workspace and which agent are
655
+ * meant have to be named on every request.
656
+ */
657
+ 'synomem-workspace-id': this.workspaceId,
658
+ ...(this.actor.kind === 'agent' ? { 'synomem-agent-id': this.actor.id } : {}),
650
659
  ...(body ? { 'content-type': 'application/json' } : {}),
651
660
  ...(idempotencyKey ? { 'idempotency-key': idempotencyKey } : {}),
652
661
  },