@go-labs-sg/registration 1.1.2 → 1.2.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.
Files changed (2) hide show
  1. package/dist/index.js +230 -28
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -372,6 +372,20 @@ var import_dotenv = __toESM(require_main(), 1);
372
372
  import path from "path";
373
373
  import_dotenv.config({ path: path.resolve(process.cwd(), ".env"), quiet: true });
374
374
 
375
+ // ../../cli-auth/src/client/credential-store.ts
376
+ import { createHash as createHash2, randomUUID } from "crypto";
377
+ import {
378
+ chmod,
379
+ lstat,
380
+ mkdir,
381
+ open as open2,
382
+ readFile,
383
+ stat,
384
+ unlink
385
+ } from "fs/promises";
386
+ import { userInfo } from "os";
387
+ import { join } from "path";
388
+
375
389
  // ../../cli-auth/src/protocol/constants.ts
376
390
  var CLI_ACCESS_TOKEN_TTL_MS = 15 * 60 * 1000;
377
391
  var CLI_DEVICE_AUTHORIZATION_TTL_MS = 10 * 60 * 1000;
@@ -19025,13 +19039,15 @@ var deviceTokenErrorSchema = exports_external.object({
19025
19039
  "authorization_pending",
19026
19040
  "slow_down",
19027
19041
  "access_denied",
19028
- "expired_token"
19042
+ "expired_token",
19043
+ "invalid_grant"
19029
19044
  ])
19030
19045
  }).strict();
19031
19046
  var refreshRequestSchema = exports_external.object({ refreshToken: exports_external.string().min(40) }).strict();
19032
19047
  var storedCliCredentialsSchema = exports_external.object({
19033
19048
  accessToken: exports_external.string().min(1),
19034
19049
  accessTokenExpiresAt: dateTimeString,
19050
+ issuer: exports_external.string().url().optional(),
19035
19051
  refreshToken: exports_external.string().min(1),
19036
19052
  refreshTokenExpiresAt: dateTimeString,
19037
19053
  session: cliSessionIdentitySchema
@@ -19083,6 +19099,77 @@ var defaultSecrets = () => {
19083
19099
  assertBunRuntime();
19084
19100
  return Bun.secrets;
19085
19101
  };
19102
+ var refreshLockTimeoutMs = 5 * 60 * 1000;
19103
+ var refreshLockRetryMs = 50;
19104
+ var getCredentialLockDirectoryPath = () => {
19105
+ return join(userInfo().homedir, ".cache", "go-labs-cli", "locks");
19106
+ };
19107
+ var ensurePrivateCredentialLockDirectory = async () => {
19108
+ const directory = getCredentialLockDirectoryPath();
19109
+ await mkdir(directory, { mode: 448, recursive: true });
19110
+ const inspectDirectory = async () => {
19111
+ const directoryStat2 = await lstat(directory);
19112
+ if (directoryStat2.isSymbolicLink() || !directoryStat2.isDirectory()) {
19113
+ throw new Error("The CLI credential lock path is not a private directory.");
19114
+ }
19115
+ const uid = process.getuid?.();
19116
+ if (uid !== undefined && directoryStat2.uid !== uid) {
19117
+ throw new Error("The CLI credential lock directory belongs to another user.");
19118
+ }
19119
+ return directoryStat2;
19120
+ };
19121
+ const directoryStat = await inspectDirectory();
19122
+ if ((directoryStat.mode & 63) !== 0) {
19123
+ await chmod(directory, 448);
19124
+ const securedDirectoryStat = await inspectDirectory();
19125
+ if ((securedDirectoryStat.mode & 63) !== 0) {
19126
+ throw new Error("Unable to secure the CLI credential lock directory.");
19127
+ }
19128
+ }
19129
+ return directory;
19130
+ };
19131
+ var getCredentialLockPath = async (product, account) => {
19132
+ const key = createHash2("sha256").update(`${product}:${account}`).digest("hex");
19133
+ return join(await ensurePrivateCredentialLockDirectory(), `${key}.lock`);
19134
+ };
19135
+ var acquireRefreshLock = async (path2) => {
19136
+ const deadline = Date.now() + refreshLockTimeoutMs;
19137
+ while (Date.now() < deadline) {
19138
+ try {
19139
+ const handle = await open2(path2, "wx");
19140
+ const owner = randomUUID();
19141
+ try {
19142
+ await handle.writeFile(owner);
19143
+ } catch (cause) {
19144
+ await handle.close().catch(() => {
19145
+ return;
19146
+ });
19147
+ await unlink(path2).catch(() => {
19148
+ return;
19149
+ });
19150
+ throw cause;
19151
+ }
19152
+ return { handle, owner };
19153
+ } catch (cause) {
19154
+ if (!(cause instanceof Error) || !("code" in cause) || cause.code !== "EEXIST") {
19155
+ throw cause;
19156
+ }
19157
+ try {
19158
+ const lock = await stat(path2);
19159
+ if (Date.now() - lock.mtimeMs > refreshLockTimeoutMs) {
19160
+ throw new Error(`The CLI credential lock appears stale at ${path2}. Ensure no CLI authentication command is running, remove that lock file, and retry.`);
19161
+ }
19162
+ } catch (statCause) {
19163
+ if (statCause instanceof Error && "code" in statCause && statCause.code === "ENOENT") {
19164
+ continue;
19165
+ }
19166
+ throw statCause;
19167
+ }
19168
+ await Bun.sleep(refreshLockRetryMs);
19169
+ }
19170
+ }
19171
+ throw new Error("Timed out waiting for another CLI process to refresh credentials. Retry the command.");
19172
+ };
19086
19173
  var createBunCredentialStore = ({
19087
19174
  account = "default",
19088
19175
  product,
@@ -19110,16 +19197,46 @@ var createBunCredentialStore = ({
19110
19197
  service,
19111
19198
  value: JSON.stringify(parsed)
19112
19199
  }));
19200
+ },
19201
+ withCredentialLock: async (operation) => {
19202
+ const refreshLockPath = await getCredentialLockPath(product, account);
19203
+ const lock = await acquireRefreshLock(refreshLockPath);
19204
+ try {
19205
+ return await operation();
19206
+ } finally {
19207
+ await lock.handle.close();
19208
+ const owner = await readFile(refreshLockPath, "utf8").catch(() => null);
19209
+ if (owner === lock.owner) {
19210
+ await unlink(refreshLockPath).catch(() => {
19211
+ return;
19212
+ });
19213
+ }
19214
+ }
19113
19215
  }
19114
19216
  };
19115
19217
  };
19116
19218
  // ../../cli-auth/src/client/login.ts
19117
19219
  import { hostname as hostname3 } from "os";
19118
- var normalizeBaseUrl = (value) => value.replace(/\/+$/, "");
19119
- var postJson = async (fetchImplementation, url2, body) => fetchImplementation(url2, {
19220
+ var normalizeCliAuthIssuer = (value) => {
19221
+ const url2 = new URL(value);
19222
+ url2.hash = "";
19223
+ url2.search = "";
19224
+ url2.pathname = url2.pathname.replace(/\/+$/, "");
19225
+ return url2.toString().replace(/\/+$/, "");
19226
+ };
19227
+ var isCliCredentialForIssuer = (credentials, apiBaseUrl) => credentials.issuer === normalizeCliAuthIssuer(apiBaseUrl);
19228
+ var credentialIssuerError = (issuer) => issuer ? new Error("These CLI credentials belong to a different API URL. Run auth login for this API URL before retrying.") : new Error("These CLI credentials were created by an older CLI and are not bound to an API URL. Run auth login again.");
19229
+ var assertCredentialIssuer = (credentials, apiBaseUrl) => {
19230
+ const expectedIssuer = normalizeCliAuthIssuer(apiBaseUrl);
19231
+ if (!credentials.issuer || credentials.issuer !== expectedIssuer) {
19232
+ throw credentialIssuerError(credentials.issuer);
19233
+ }
19234
+ };
19235
+ var postJson = async (fetchImplementation, url2, body, options = {}) => fetchImplementation(url2, {
19120
19236
  body: JSON.stringify(body),
19121
19237
  headers: { "content-type": "application/json" },
19122
- method: "POST"
19238
+ method: "POST",
19239
+ ...options
19123
19240
  });
19124
19241
  var readJson = async (response) => {
19125
19242
  try {
@@ -19128,9 +19245,29 @@ var readJson = async (response) => {
19128
19245
  throw new Error(`CLI authentication returned HTTP ${response.status} without valid JSON.`);
19129
19246
  }
19130
19247
  };
19131
- var toStoredCredentials = (response, now) => ({
19248
+ var localCredentialLocks = new WeakMap;
19249
+ var withCredentialLock = (store, operation) => {
19250
+ if (store.withCredentialLock) {
19251
+ return store.withCredentialLock(operation);
19252
+ }
19253
+ const previous = localCredentialLocks.get(store) ?? Promise.resolve();
19254
+ const pending = previous.then(operation, operation);
19255
+ const tail = pending.then(() => {
19256
+ return;
19257
+ }, () => {
19258
+ return;
19259
+ });
19260
+ localCredentialLocks.set(store, tail);
19261
+ return pending.finally(() => {
19262
+ if (localCredentialLocks.get(store) === tail) {
19263
+ localCredentialLocks.delete(store);
19264
+ }
19265
+ });
19266
+ };
19267
+ var toStoredCredentials = (response, issuer, now) => ({
19132
19268
  accessToken: response.accessToken,
19133
19269
  accessTokenExpiresAt: new Date(now.getTime() + response.expiresIn * 1000).toISOString(),
19270
+ issuer,
19134
19271
  refreshToken: response.refreshToken,
19135
19272
  refreshTokenExpiresAt: new Date(now.getTime() + response.refreshExpiresIn * 1000).toISOString(),
19136
19273
  session: response.session
@@ -19164,7 +19301,7 @@ var loginWithBrowser = async ({
19164
19301
  sleep = (milliseconds) => Bun.sleep(milliseconds),
19165
19302
  store
19166
19303
  }) => {
19167
- const baseUrl = normalizeBaseUrl(apiBaseUrl);
19304
+ const baseUrl = normalizeCliAuthIssuer(apiBaseUrl);
19168
19305
  const pkce = createPkcePair();
19169
19306
  const beginResponse = await postJson(fetchImplementation, `${baseUrl}/api/cli-auth/device`, {
19170
19307
  clientName,
@@ -19191,8 +19328,8 @@ var loginWithBrowser = async ({
19191
19328
  const payload = await readJson(response);
19192
19329
  if (response.ok) {
19193
19330
  const issued = tokenResponseSchema.parse(payload);
19194
- const credentials = toStoredCredentials(issued, now());
19195
- await store.set(credentials);
19331
+ const credentials = toStoredCredentials(issued, baseUrl, now());
19332
+ await withCredentialLock(store, () => store.set(credentials));
19196
19333
  return credentials;
19197
19334
  }
19198
19335
  const error61 = deviceTokenErrorSchema.safeParse(payload);
@@ -19213,6 +19350,26 @@ var loginWithBrowser = async ({
19213
19350
  throw new Error("CLI authentication expired. Run auth login again.");
19214
19351
  };
19215
19352
  var refreshes = new WeakMap;
19353
+ var refreshRequestTimeoutMs = 30000;
19354
+ var isInvalidGrantResponse = (payload) => {
19355
+ const parsed = deviceTokenErrorSchema.safeParse(payload);
19356
+ return parsed.success && parsed.data.error === "invalid_grant";
19357
+ };
19358
+ var readJsonOrNull = async (response) => {
19359
+ try {
19360
+ return await response.json();
19361
+ } catch {
19362
+ return null;
19363
+ }
19364
+ };
19365
+ var deleteInvalidCredentials = async (store, credentials) => {
19366
+ const current = await store.get();
19367
+ if (!current)
19368
+ return;
19369
+ if (current.issuer === credentials.issuer && current.refreshToken === credentials.refreshToken) {
19370
+ await store.delete();
19371
+ }
19372
+ };
19216
19373
  var refreshAccessToken = async ({
19217
19374
  apiBaseUrl,
19218
19375
  credentials,
@@ -19220,16 +19377,56 @@ var refreshAccessToken = async ({
19220
19377
  now,
19221
19378
  store
19222
19379
  }) => {
19223
- const response = await postJson(fetchImplementation, `${normalizeBaseUrl(apiBaseUrl)}/api/cli-auth/refresh`, { refreshToken: credentials.refreshToken });
19380
+ assertCredentialIssuer(credentials, apiBaseUrl);
19381
+ let response;
19382
+ try {
19383
+ response = await postJson(fetchImplementation, `${normalizeCliAuthIssuer(apiBaseUrl)}/api/cli-auth/refresh`, { refreshToken: credentials.refreshToken }, { signal: AbortSignal.timeout(refreshRequestTimeoutMs) });
19384
+ } catch {
19385
+ throw new Error("Unable to refresh the CLI session because the API could not be reached. Credentials were kept; retry the command.");
19386
+ }
19224
19387
  if (!response.ok) {
19225
- await store.delete();
19226
- throw new Error("The CLI session expired or was revoked. Run auth login again.");
19388
+ const payload2 = await readJsonOrNull(response);
19389
+ if (isInvalidGrantResponse(payload2)) {
19390
+ await deleteInvalidCredentials(store, credentials);
19391
+ throw new Error("The CLI session expired or was revoked. Run auth login again.");
19392
+ }
19393
+ throw new Error(`Unable to refresh the CLI session (HTTP ${response.status}). Credentials were kept; retry the command.`);
19394
+ }
19395
+ const payload = await readJsonOrNull(response);
19396
+ if (payload === null) {
19397
+ throw new Error("Unable to refresh the CLI session because the API returned malformed JSON. Credentials were kept; retry the command.");
19398
+ }
19399
+ let issued;
19400
+ try {
19401
+ issued = tokenResponseSchema.parse(payload);
19402
+ } catch {
19403
+ throw new Error("Unable to refresh the CLI session because the API returned invalid credentials. Credentials were kept; retry the command.");
19227
19404
  }
19228
- const issued = tokenResponseSchema.parse(await readJson(response));
19229
- const stored = toStoredCredentials(issued, now());
19405
+ const stored = toStoredCredentials(issued, normalizeCliAuthIssuer(apiBaseUrl), now());
19230
19406
  await store.set(stored);
19231
19407
  return stored.accessToken;
19232
19408
  };
19409
+ var refreshWithCurrentCredentials = async ({
19410
+ apiBaseUrl,
19411
+ fetch: fetchImplementation,
19412
+ now,
19413
+ store
19414
+ }) => {
19415
+ const credentials = await store.get();
19416
+ if (!credentials)
19417
+ throw new Error("Not authenticated. Run auth login first.");
19418
+ assertCredentialIssuer(credentials, apiBaseUrl);
19419
+ if (Date.parse(credentials.accessTokenExpiresAt) - now().getTime() > 60000) {
19420
+ return credentials.accessToken;
19421
+ }
19422
+ return refreshAccessToken({
19423
+ apiBaseUrl,
19424
+ credentials,
19425
+ fetch: fetchImplementation,
19426
+ now,
19427
+ store
19428
+ });
19429
+ };
19233
19430
  var getAuthenticatedAccessToken = async ({
19234
19431
  apiBaseUrl,
19235
19432
  fetch: fetchImplementation = globalThis.fetch,
@@ -19239,19 +19436,20 @@ var getAuthenticatedAccessToken = async ({
19239
19436
  const credentials = await store.get();
19240
19437
  if (!credentials)
19241
19438
  throw new Error("Not authenticated. Run auth login first.");
19439
+ assertCredentialIssuer(credentials, apiBaseUrl);
19242
19440
  if (Date.parse(credentials.accessTokenExpiresAt) - now().getTime() > 60000) {
19243
19441
  return credentials.accessToken;
19244
19442
  }
19245
19443
  const existing = refreshes.get(store);
19246
19444
  if (existing)
19247
19445
  return existing;
19248
- const refresh = refreshAccessToken({
19446
+ const refreshOperation = () => withCredentialLock(store, () => refreshWithCurrentCredentials({
19249
19447
  apiBaseUrl,
19250
- credentials,
19251
19448
  fetch: fetchImplementation,
19252
19449
  now,
19253
19450
  store
19254
- }).finally(() => refreshes.delete(store));
19451
+ }));
19452
+ const refresh = refreshOperation().finally(() => refreshes.delete(store));
19255
19453
  refreshes.set(store, refresh);
19256
19454
  return refresh;
19257
19455
  };
@@ -19260,18 +19458,22 @@ var logoutCliSession = async ({
19260
19458
  fetch: fetchImplementation = globalThis.fetch,
19261
19459
  store
19262
19460
  }) => {
19263
- const credentials = await store.get();
19264
- let remoteRevoked = credentials === null;
19265
- if (credentials) {
19266
- try {
19267
- const response = await postJson(fetchImplementation, `${normalizeBaseUrl(apiBaseUrl)}/api/cli-auth/revoke`, { refreshToken: credentials.refreshToken });
19268
- remoteRevoked = response.ok;
19269
- } catch {
19270
- remoteRevoked = false;
19461
+ const issuer = normalizeCliAuthIssuer(apiBaseUrl);
19462
+ return withCredentialLock(store, async () => {
19463
+ const credentials = await store.get();
19464
+ let remoteRevoked = credentials === null;
19465
+ if (credentials) {
19466
+ try {
19467
+ assertCredentialIssuer(credentials, apiBaseUrl);
19468
+ const response = await postJson(fetchImplementation, `${issuer}/api/cli-auth/revoke`, { refreshToken: credentials.refreshToken }, { signal: AbortSignal.timeout(refreshRequestTimeoutMs) });
19469
+ remoteRevoked = response.ok;
19470
+ } catch {
19471
+ remoteRevoked = false;
19472
+ }
19271
19473
  }
19272
- }
19273
- await store.delete();
19274
- return { remoteRevoked };
19474
+ await store.delete();
19475
+ return { remoteRevoked };
19476
+ });
19275
19477
  };
19276
19478
  // src/cli.ts
19277
19479
  import { readFileSync } from "fs";
@@ -19921,7 +20123,7 @@ Waiting for approval\u2026
19921
20123
  const credentials = await credentialStore.get();
19922
20124
  write(io.stdout, {
19923
20125
  ok: true,
19924
- data: credentials ? {
20126
+ data: credentials && isCliCredentialForIssuer(credentials, apiBaseUrl) ? {
19925
20127
  authenticated: true,
19926
20128
  accessTokenExpiresAt: credentials.accessTokenExpiresAt,
19927
20129
  refreshTokenExpiresAt: credentials.refreshTokenExpiresAt,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@go-labs-sg/registration",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "Registration System CLI for organizations, events, forms, registrations, and attendee operations.",
5
5
  "type": "module",
6
6
  "bin": {