@andco/sdk 0.0.2 → 0.0.4

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 (83) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +96 -54
  3. package/dist/auth.d.ts +8 -3
  4. package/dist/auth.d.ts.map +1 -1
  5. package/dist/auth.js +32 -23
  6. package/dist/browser/controller.d.ts +55 -1
  7. package/dist/browser/controller.d.ts.map +1 -1
  8. package/dist/browser/controller.js +166 -9
  9. package/dist/browser/frame.d.ts +4 -3
  10. package/dist/browser/frame.d.ts.map +1 -1
  11. package/dist/browser/frame.js +12 -20
  12. package/dist/browser/index.d.ts +18 -6
  13. package/dist/browser/index.d.ts.map +1 -1
  14. package/dist/browser/index.js +64 -29
  15. package/dist/browser/popup.d.ts +6 -1
  16. package/dist/browser/popup.d.ts.map +1 -1
  17. package/dist/browser/popup.js +37 -8
  18. package/dist/cli/index.d.ts +4 -2
  19. package/dist/cli/index.d.ts.map +1 -1
  20. package/dist/cli/index.js +10 -3
  21. package/dist/cli/server.d.ts +5 -0
  22. package/dist/cli/server.d.ts.map +1 -1
  23. package/dist/cli/server.js +5 -0
  24. package/dist/client.d.ts +66 -19
  25. package/dist/client.d.ts.map +1 -1
  26. package/dist/client.js +125 -74
  27. package/dist/config.d.ts +2 -1
  28. package/dist/config.d.ts.map +1 -1
  29. package/dist/config.js +1 -0
  30. package/dist/credentials.d.ts +58 -4
  31. package/dist/credentials.d.ts.map +1 -1
  32. package/dist/credentials.js +0 -0
  33. package/dist/errors.d.ts +23 -21
  34. package/dist/errors.d.ts.map +1 -1
  35. package/dist/errors.js +18 -20
  36. package/dist/globals.d.ts +25 -0
  37. package/dist/globals.d.ts.map +1 -0
  38. package/dist/globals.js +15 -0
  39. package/dist/grants-api.d.ts +34 -0
  40. package/dist/grants-api.d.ts.map +1 -0
  41. package/dist/grants-api.js +48 -0
  42. package/dist/grants.d.ts +16 -0
  43. package/dist/grants.d.ts.map +1 -0
  44. package/dist/grants.js +13 -0
  45. package/dist/index.d.ts +12 -6
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +8 -2
  48. package/dist/inflight.d.ts +31 -0
  49. package/dist/inflight.d.ts.map +1 -0
  50. package/dist/inflight.js +26 -0
  51. package/dist/intents.d.ts +386 -40
  52. package/dist/intents.d.ts.map +1 -1
  53. package/dist/intents.js +712 -53
  54. package/dist/oauth.d.ts +55 -6
  55. package/dist/oauth.d.ts.map +1 -1
  56. package/dist/oauth.js +86 -57
  57. package/dist/presenter.d.ts +59 -11
  58. package/dist/presenter.d.ts.map +1 -1
  59. package/dist/presenter.js +40 -1
  60. package/dist/resource.d.ts +109 -0
  61. package/dist/resource.d.ts.map +1 -0
  62. package/dist/resource.js +151 -0
  63. package/dist/rest.d.ts +23 -4
  64. package/dist/rest.d.ts.map +1 -1
  65. package/dist/rest.js +46 -5
  66. package/dist/server/index.d.ts +3 -0
  67. package/dist/server/index.d.ts.map +1 -1
  68. package/dist/server/index.js +1 -0
  69. package/dist/server-metadata.generated.d.ts.map +1 -1
  70. package/dist/server-metadata.generated.js +8 -4
  71. package/dist/service.d.ts +109 -0
  72. package/dist/service.d.ts.map +1 -0
  73. package/dist/service.js +241 -0
  74. package/dist/session-store.d.ts +12 -4
  75. package/dist/session-store.d.ts.map +1 -1
  76. package/dist/session-store.js +64 -14
  77. package/dist/storage.d.ts +16 -23
  78. package/dist/storage.d.ts.map +1 -1
  79. package/dist/storage.js +27 -25
  80. package/dist/tokens.d.ts +46 -0
  81. package/dist/tokens.d.ts.map +1 -0
  82. package/dist/tokens.js +148 -0
  83. package/package.json +13 -3
@@ -0,0 +1,241 @@
1
+ import { ANDCO_API_ORIGIN, assertAndCoResource, parseServiceGrants, parseServiceSubject, parseServiceToken, } from "@andco/protocol";
2
+ import { AndcoConfig } from "./config.js";
3
+ import { ANDCO_REFRESH_SKEW_SECONDS } from "./credentials.js";
4
+ import { AndcoError } from "./errors.js";
5
+ import { resolveGlobals } from "./globals.js";
6
+ import { grantId } from "./grants.js";
7
+ import { AndcoIntents } from "./intents.js";
8
+ import { AndcoRest } from "./rest.js";
9
+ /** No approved Grant covers this subject and resource. Version one never opens a login flow. */
10
+ export class AndcoAuthorizationRequiredError extends AndcoError {
11
+ subject;
12
+ resource;
13
+ constructor(subject, resource) {
14
+ super("authorization_required", {
15
+ message: "No approved Grant covers this subject and resource; approve one in Platform",
16
+ details: { subject, resource },
17
+ });
18
+ this.subject = subject;
19
+ this.resource = resource;
20
+ }
21
+ }
22
+ /**
23
+ * Confidential client acting for Organizations through Grants approved in Platform.
24
+ *
25
+ * Each call sends the subject and the resource, and the Authorization Server selects the Grant.
26
+ * When more than one Grant qualifies the exchange fails with `ambiguous_grant`, whose `details`
27
+ * name the candidate ids; pass one as `grant` to pin it. Access tokens live in memory only.
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * const service = createAndcoService({ clientId, clientSecret });
32
+ * const bank = new Bank(service.credentials({ subject: { type: "organization", identifier: "123" } }));
33
+ * const { data } = await bank.client.http.GET("/accounts").throwOnError();
34
+ * await service.intents({ subject: { type: "organization", identifier: "123" } }).approve(transferId);
35
+ * ```
36
+ */
37
+ export class AndcoService {
38
+ config;
39
+ rest;
40
+ #subject;
41
+ #resource;
42
+ #secret;
43
+ #globals;
44
+ #tokens = new Map();
45
+ #pending = new Map();
46
+ constructor(options) {
47
+ if (typeof window !== "undefined")
48
+ throw new AndcoError("browser_forbidden", { message: "Service credentials cannot be used in a browser" });
49
+ if (typeof options.clientSecret !== "string" || !options.clientSecret)
50
+ throw new AndcoError("invalid_configuration", { message: "Service requires clientSecret" });
51
+ this.config = AndcoConfig.from(options);
52
+ this.#subject = options.subject === undefined ? undefined : parseSubject(options.subject);
53
+ this.#resource = options.resource ?? ANDCO_API_ORIGIN;
54
+ assertAndCoResource(this.#resource);
55
+ this.#secret = options.clientSecret;
56
+ this.#globals = resolveGlobals(options.globals);
57
+ this.rest = new AndcoRest({
58
+ config: this.config,
59
+ globals: this.#globals,
60
+ credentials: this.credentials(),
61
+ resource: this.#resource,
62
+ });
63
+ }
64
+ /**
65
+ * Credentials for Bank or another Resource Server definition, acting for one Organization.
66
+ *
67
+ * @example
68
+ * ```ts
69
+ * const bank = new Bank(service.credentials({ subject: { type: "organization", identifier: "123" } }));
70
+ * ```
71
+ */
72
+ credentials(options = {}) {
73
+ const subject = options.subject === undefined ? this.#subject : parseSubject(options.subject);
74
+ const grant = options.grant === undefined ? undefined : grantId(options.grant);
75
+ return this.#credentials(subject, grant);
76
+ }
77
+ /**
78
+ * The Intent lifecycle (`approve`, `reject`, `cancel`, `get`, `events`, `subscribe`) acting for
79
+ * one Organization: the same {@link AndcoIntents} `andco.intents` exposes, authorized with
80
+ * `credentials(options)`. A method rather than a property, like `credentials()`, because the
81
+ * subject and the Grant are chosen per call; without options it acts for the Service's `subject`.
82
+ * Presentation needs a person and a browser, so `present()` and `presentationURL()` are not for a
83
+ * Service.
84
+ *
85
+ * @example
86
+ * ```ts
87
+ * const intents = service.intents({ subject: { type: "organization", identifier: "123" } });
88
+ * await intents.approve(transferId); // the server selects the Grant holding bank_transfer approve
89
+ * await service.intents({ grant: approverGrantId }).reject(transferId, { reason: "over_budget" });
90
+ * const { data: transfer } = await service.intents().get(transferId);
91
+ * ```
92
+ */
93
+ intents(options = {}) {
94
+ console.debug("[AndcoService.intents] binding Intents for %o grant %s", options.subject ?? this.#subject, options.grant === undefined ? "(server)" : grantId(options.grant));
95
+ const rest = new AndcoRest({
96
+ config: this.config,
97
+ globals: this.#globals,
98
+ credentials: this.credentials(options),
99
+ resource: this.#resource,
100
+ });
101
+ return new AndcoIntents({ rest, config: this.config, globals: this.#globals });
102
+ }
103
+ /**
104
+ * Lists the active Grants the server would select from for one subject and resource.
105
+ *
106
+ * @example
107
+ * ```ts
108
+ * const grants = await service.listGrants({ subject: { type: "organization", identifier: "123" } });
109
+ * const bank = new Bank(service.credentials({ subject, grant: grants[0] }));
110
+ * ```
111
+ */
112
+ async listGrants(options = {}) {
113
+ const subject = this.#require(options.subject === undefined ? this.#subject : parseSubject(options.subject));
114
+ const resource = options.resource ?? this.#resource;
115
+ assertAndCoResource(resource);
116
+ const grants = parseServiceGrants(await this.#exchange("oauth/grants", parameters(subject, resource)));
117
+ console.debug("[AndcoService.listGrants] %s Grants for %o on %s", grants.length, subject, resource);
118
+ return grants;
119
+ }
120
+ #credentials(subject, fixed, pin) {
121
+ const credentials = {
122
+ config: this.config,
123
+ forOperation: () => this.#credentials(subject, fixed, {}),
124
+ accessTokenFor: async (resource, request) => {
125
+ assertAndCoResource(resource);
126
+ const target = this.#require(subject);
127
+ const explicit = request?.grant === undefined ? fixed : grantId(request.grant);
128
+ if (pin?.grant && explicit !== undefined && explicit !== pin.grant)
129
+ throw new AndcoError("invalid_grant", { message: "Operation authority is already pinned" });
130
+ const grant = explicit ?? pin?.grant;
131
+ const issued = await this.#token(target, resource, grant);
132
+ if (pin)
133
+ pin.grant = issued.grantId;
134
+ return issued.token;
135
+ },
136
+ };
137
+ return credentials;
138
+ }
139
+ #require(subject) {
140
+ if (subject === undefined)
141
+ throw new AndcoError("invalid_configuration", {
142
+ message: "Service requires a subject: pass one to createAndcoService() or service.credentials()",
143
+ });
144
+ return subject;
145
+ }
146
+ async #token(subject, resource, grant) {
147
+ const now = Date.now() / 1000;
148
+ for (const [key, cached] of this.#tokens)
149
+ if (cached.expiresAt <= now + ANDCO_REFRESH_SKEW_SECONDS)
150
+ this.#tokens.delete(key);
151
+ const body = parameters(subject, resource);
152
+ body.set("grant_type", "client_credentials");
153
+ if (grant !== undefined)
154
+ body.set("grant_id", grant);
155
+ const key = body.toString();
156
+ const cached = this.#tokens.get(key);
157
+ if (cached)
158
+ return { token: cached.token, grantId: cached.grantId };
159
+ const pending = this.#pending.get(key);
160
+ if (pending)
161
+ return pending;
162
+ const acquire = (async () => {
163
+ console.debug("[AndcoService.token] exchanging for %o on %s grant %s", subject, resource, grant ?? "(server)");
164
+ let payload;
165
+ try {
166
+ payload = await this.#exchange("oauth/token", body);
167
+ }
168
+ catch (error) {
169
+ if (AndcoError.is(error) && error.code === "authorization_required")
170
+ throw new AndcoAuthorizationRequiredError(subject, resource);
171
+ throw error;
172
+ }
173
+ const token = parseServiceToken(payload);
174
+ if (grant !== undefined && token.authorization_grant_id !== grant)
175
+ throw new AndcoError("invalid_response", { message: "Issuer substituted the explicit Grant" });
176
+ const issued = {
177
+ token: token.access_token,
178
+ expiresAt: Date.now() / 1000 + token.expires_in,
179
+ grantId: token.authorization_grant_id,
180
+ };
181
+ this.#tokens.set(key, issued);
182
+ // A server-selected token is also the token of its exact Grant, so a pinned operation reuses it.
183
+ const exact = new URLSearchParams(body);
184
+ exact.set("grant_id", token.authorization_grant_id);
185
+ this.#tokens.set(exact.toString(), issued);
186
+ console.debug("[AndcoService.token] issued with Grant %s", token.authorization_grant_id);
187
+ return { token: token.access_token, grantId: token.authorization_grant_id };
188
+ })();
189
+ this.#pending.set(key, acquire);
190
+ try {
191
+ return await acquire;
192
+ }
193
+ finally {
194
+ this.#pending.delete(key);
195
+ }
196
+ }
197
+ async #exchange(path, body) {
198
+ const basic = btoa(`${encodeURIComponent(this.config.clientId)}:${encodeURIComponent(this.#secret)}`);
199
+ const response = await this.#globals.fetch(this.config.authURL(path), {
200
+ method: "POST",
201
+ headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/x-www-form-urlencoded" },
202
+ body,
203
+ redirect: "error",
204
+ });
205
+ const payload = await response.json().catch((cause) => {
206
+ throw new AndcoError("invalid_response", {
207
+ status: response.status,
208
+ cause,
209
+ message: "Issuer returned invalid JSON",
210
+ });
211
+ });
212
+ if (!response.ok) {
213
+ const { error, error_description, ...details } = payload && typeof payload === "object" ? payload : {};
214
+ console.debug("[AndcoService.exchange] %s failed %s %s", path, response.status, error);
215
+ throw new AndcoError(typeof error === "string" ? error : "invalid_response", {
216
+ status: response.status,
217
+ message: typeof error_description === "string" ? error_description : undefined,
218
+ details: Object.keys(details).length ? details : undefined,
219
+ });
220
+ }
221
+ return payload;
222
+ }
223
+ }
224
+ function parseSubject(subject) {
225
+ try {
226
+ return parseServiceSubject(subject);
227
+ }
228
+ catch (cause) {
229
+ throw new AndcoError("invalid_configuration", {
230
+ cause,
231
+ message: 'A Service subject is { type: "organization", identifier } with an exact Organization id',
232
+ });
233
+ }
234
+ }
235
+ function parameters(subject, resource) {
236
+ return new URLSearchParams({ subject: JSON.stringify(subject), resource });
237
+ }
238
+ /** Constructs a service lazily; does not perform network I/O or approve access. */
239
+ export function createAndcoService(options) {
240
+ return new AndcoService(options);
241
+ }
@@ -1,21 +1,24 @@
1
1
  import type { AndcoConfig } from "./config.js";
2
2
  import { type AndcoCredentials, type AndcoSession } from "./credentials.js";
3
3
  import { AndcoError, Result } from "./errors.js";
4
+ import { type AndcoInflight } from "./inflight.js";
4
5
  import type { AndcoOAuth } from "./oauth.js";
5
- import { type AndcoLock, type AndcoStorage } from "./storage.js";
6
+ import { type AndcoStorage } from "./storage.js";
6
7
  /** Reads a session that arrived from somewhere other than storage, such as a URL callback. */
7
8
  export type AndcoSessionSource = () => Promise<Result<AndcoSession | null>>;
9
+ /** Lazy session loading, persistence, refresh, and initial hydration options. */
8
10
  export type AndcoSessionStoreOptions = {
9
11
  config: AndcoConfig;
10
12
  oauth: AndcoOAuth;
13
+ /** Persists serialized sessions. Defaults to memory for this store's lifetime. */
11
14
  storage?: AndcoStorage;
12
15
  /** A session the caller already resolved. Makes the first snapshot real instead of unresolved. */
13
16
  initialSession?: AndcoSession | null;
14
- /** Serializes refresh beyond this runtime. Defaults to in-process only. */
15
- lock?: AndcoLock;
17
+ /** Shares one refresh among concurrent callers. Defaults to this runtime only. */
18
+ inflight?: AndcoInflight;
16
19
  /** Consulted once during the load, before storage. Used for URL callbacks. */
17
20
  source?: AndcoSessionSource;
18
- /** Whether a loaded session is written back to storage. */
21
+ /** Writes session changes to storage unless `false`; defaults to `true`. */
19
22
  persist?: boolean;
20
23
  };
21
24
  /**
@@ -71,6 +74,11 @@ export declare class AndcoSessionStore implements AndcoCredentials {
71
74
  * discarded instance be inert and removes any need for instance identity.
72
75
  */
73
76
  accessTokenFor(resource: string): Promise<string | null>;
77
+ /**
78
+ * Refreshes now, even if the session is still fresh. Concurrent calls, and concurrent token
79
+ * resolutions that find the session stale, share one token request.
80
+ */
81
+ refresh(): Promise<Result<AndcoSession>>;
74
82
  /**
75
83
  * Replaces the stored session. This is the supported way to restore a saved credential, which
76
84
  * previously required writing a JSON blob into the SDK's private storage key because no such
@@ -1 +1 @@
1
- {"version":3,"file":"session-store.d.ts","sourceRoot":"","sources":["../src/session-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,EAAE,KAAK,gBAAgB,EAAE,KAAK,YAAY,EAAuB,MAAM,kBAAkB,CAAC;AACjG,OAAO,EAAqB,UAAU,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AACpE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC7C,OAAO,EAAE,KAAK,SAAS,EAAE,KAAK,YAAY,EAAgC,MAAM,cAAc,CAAC;AAE/F,8FAA8F;AAC9F,MAAM,MAAM,kBAAkB,GAAG,MAAM,OAAO,CAAC,MAAM,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC,CAAC;AAE5E,MAAM,MAAM,wBAAwB,GAAG;IACrC,MAAM,EAAE,WAAW,CAAC;IACpB,KAAK,EAAE,UAAU,CAAC;IAClB,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,kGAAkG;IAClG,cAAc,CAAC,EAAE,YAAY,GAAG,IAAI,CAAC;IACrC,2EAA2E;IAC3E,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,8EAA8E;IAC9E,MAAM,CAAC,EAAE,kBAAkB,CAAC;IAC5B,2DAA2D;IAC3D,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,qBAAa,iBAAkB,YAAW,gBAAgB;;gBAWrC,OAAO,EAAE,wBAAwB;IASpD;;;;;;OAMG;IACI,WAAW,IAAI,YAAY,GAAG,IAAI,GAAG,SAAS;IAIrD,4FAA4F;IACrF,iBAAiB,IAAI,YAAY,GAAG,IAAI,GAAG,SAAS;IAI3D,sFAAsF;IAC/E,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI;IAQlD;;;;;;OAMG;IACH,IAAW,KAAK,IAAI,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAExC;IAED;;;;;OAKG;IACU,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAsBrE;;;;OAIG;IACU,GAAG,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;CAsDtE;AAgBD,6EAA6E;AAC7E,wBAAgB,eAAe,IAAI,UAAU,CAE5C"}
1
+ {"version":3,"file":"session-store.d.ts","sourceRoot":"","sources":["../src/session-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,EAAE,KAAK,gBAAgB,EAAE,KAAK,YAAY,EAAuB,MAAM,kBAAkB,CAAC;AACjG,OAAO,EAAqB,UAAU,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AACpE,OAAO,EAAE,KAAK,aAAa,EAAkB,MAAM,eAAe,CAAC;AACnE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC7C,OAAO,EAAE,KAAK,YAAY,EAAiB,MAAM,cAAc,CAAC;AAEhE,8FAA8F;AAC9F,MAAM,MAAM,kBAAkB,GAAG,MAAM,OAAO,CAAC,MAAM,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC,CAAC;AAE5E,iFAAiF;AACjF,MAAM,MAAM,wBAAwB,GAAG;IACrC,MAAM,EAAE,WAAW,CAAC;IACpB,KAAK,EAAE,UAAU,CAAC;IAClB,kFAAkF;IAClF,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,kGAAkG;IAClG,cAAc,CAAC,EAAE,YAAY,GAAG,IAAI,CAAC;IACrC,kFAAkF;IAClF,QAAQ,CAAC,EAAE,aAAa,CAAC;IACzB,8EAA8E;IAC9E,MAAM,CAAC,EAAE,kBAAkB,CAAC;IAC5B,4EAA4E;IAC5E,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,qBAAa,iBAAkB,YAAW,gBAAgB;;gBAWrC,OAAO,EAAE,wBAAwB;IASpD;;;;;;OAMG;IACI,WAAW,IAAI,YAAY,GAAG,IAAI,GAAG,SAAS;IAIrD,4FAA4F;IACrF,iBAAiB,IAAI,YAAY,GAAG,IAAI,GAAG,SAAS;IAI3D,sFAAsF;IAC/E,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI;IAQlD;;;;;;OAMG;IACH,IAAW,KAAK,IAAI,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAExC;IAED;;;;;OAKG;IACU,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAarE;;;OAGG;IACU,OAAO,IAAI,OAAO,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC;IAuBrD;;;;OAIG;IACU,GAAG,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;CAmFtE;AAgBD,6EAA6E;AAC7E,wBAAgB,eAAe,IAAI,UAAU,CAE5C"}
@@ -1,6 +1,7 @@
1
1
  import { isExpired, tokenFor } from "./credentials.js";
2
2
  import { ANDCO_ERROR_CODES, AndcoError, Result } from "./errors.js";
3
- import { InProcessLock, MemoryStorage } from "./storage.js";
3
+ import { MemoryInflight } from "./inflight.js";
4
+ import { MemoryStorage } from "./storage.js";
4
5
  /**
5
6
  * The credential as a resource, and the only stateful object in the SDK.
6
7
  *
@@ -27,7 +28,7 @@ import { InProcessLock, MemoryStorage } from "./storage.js";
27
28
  export class AndcoSessionStore {
28
29
  #options;
29
30
  #storage;
30
- #lock;
31
+ #inflight;
31
32
  #listeners = new Set();
32
33
  #key;
33
34
  #hydrated;
@@ -36,7 +37,7 @@ export class AndcoSessionStore {
36
37
  constructor(options) {
37
38
  this.#options = options;
38
39
  this.#storage = options.storage ?? new MemoryStorage();
39
- this.#lock = options.lock ?? new InProcessLock();
40
+ this.#inflight = options.inflight ?? new MemoryInflight();
40
41
  this.#key = `andco.session.${options.config.clientId}`;
41
42
  this.#hydrated = options.initialSession;
42
43
  this.#snapshot = options.initialSession;
@@ -86,23 +87,42 @@ export class AndcoSessionStore {
86
87
  return null;
87
88
  if (!isExpired(current))
88
89
  return tokenFor(current, resource);
89
- // Serialized per resource: refreshing one Resource Server's token must not block another's.
90
- const refreshed = await this.#lock.acquire(`${this.#key}:${resource}`, async () => {
91
- const latest = this.#snapshot;
90
+ // One refresh grant renews every resource, so concurrent callers share it whatever they asked for.
91
+ const refreshed = await this.#refresh(false);
92
+ if (refreshed.error)
93
+ return null;
94
+ return refreshed.data ? tokenFor(refreshed.data, resource) : null;
95
+ }
96
+ /**
97
+ * Refreshes now, even if the session is still fresh. Concurrent calls, and concurrent token
98
+ * resolutions that find the session stale, share one token request.
99
+ */
100
+ async refresh() {
101
+ await this.#ensureLoaded();
102
+ const refreshed = await this.#refresh(true);
103
+ if (refreshed.error)
104
+ return Result.fail(refreshed.error);
105
+ if (!refreshed.data)
106
+ return Result.fail(ANDCO_ERROR_CODES.SESSION_MISSING);
107
+ return Result.ok(refreshed.data);
108
+ }
109
+ async #refresh(force) {
110
+ console.debug("[refresh] requested force=%s", force);
111
+ return this.#inflight.run(this.#key, async () => {
112
+ const latest = await this.#latest();
92
113
  if (!latest)
93
- return null;
94
- // Another holder of the lock may have refreshed while this one waited.
95
- if (!isExpired(latest))
96
- return latest;
114
+ return Result.ok(null);
115
+ // Another caller, or another tab sharing the storage, may have refreshed already.
116
+ if (!force && !isExpired(latest))
117
+ return Result.ok(latest);
97
118
  if (!latest.refreshToken)
98
- return null;
119
+ return Result.fail(ANDCO_ERROR_CODES.SESSION_MISSING);
99
120
  const result = await this.#options.oauth.refresh(latest.refreshToken);
100
121
  if (result.error)
101
- return null;
122
+ return Result.fail(result.error);
102
123
  await this.#write(result.data);
103
- return result.data;
124
+ return Result.ok(result.data);
104
125
  });
105
- return refreshed ? tokenFor(refreshed, resource) : null;
106
126
  }
107
127
  /**
108
128
  * Replaces the stored session. This is the supported way to restore a saved credential, which
@@ -148,6 +168,36 @@ export class AndcoSessionStore {
148
168
  return Result.fail(AndcoError.from(cause, ANDCO_ERROR_CODES.STORAGE_FAILED));
149
169
  }
150
170
  }
171
+ /**
172
+ * The newest session between memory and storage.
173
+ *
174
+ * Storage shared by several runtimes — `localStorage` across tabs — may hold a session another
175
+ * runtime refreshed after this one loaded. Refresh tokens rotate, so refreshing with the copy in
176
+ * memory would present a token already replaced, which the Authorization Server tolerates only
177
+ * for a short interval and otherwise treats as reuse. Storage wins only when it is newer: a
178
+ * hydrated session is never written, so an empty storage does not mean signed out.
179
+ */
180
+ async #latest() {
181
+ const current = this.#snapshot ?? null;
182
+ if (this.#options.persist === false)
183
+ return current;
184
+ let persisted;
185
+ try {
186
+ const stored = await this.#storage.getItem(this.#key);
187
+ persisted = stored ? parseSession(stored) : null;
188
+ }
189
+ catch (cause) {
190
+ console.warn("[AndcoSessionStore] could not read storage before refreshing: %o", cause);
191
+ return current;
192
+ }
193
+ if (!persisted)
194
+ return current;
195
+ if (current && persisted.expiresAt <= current.expiresAt)
196
+ return current;
197
+ console.debug("[AndcoSessionStore] adopting a newer stored session expiring at %s", persisted.expiresAt);
198
+ this.#publish(persisted);
199
+ return persisted;
200
+ }
151
201
  async #write(session) {
152
202
  if (this.#options.persist !== false) {
153
203
  if (session)
package/dist/storage.d.ts CHANGED
@@ -27,35 +27,28 @@ export declare class MemoryStorage implements AndcoStorage {
27
27
  setItem(key: string, value: string): void;
28
28
  removeItem(key: string): void;
29
29
  }
30
- /** Adapts a Web Storage object. Its synchronous methods satisfy the asynchronous-capable contract. */
31
- export declare class WebStorage implements AndcoStorage {
32
- private readonly storage;
33
- constructor(storage: Storage);
30
+ /**
31
+ * Keeps a session in `window.localStorage`: shared by every tab of the origin and kept across browser
32
+ * restarts. The browser default.
33
+ *
34
+ * The global is read on each call, not at construction, so building one during a server render is
35
+ * safe; only using it there fails.
36
+ */
37
+ export declare class LocalStorage implements AndcoStorage {
34
38
  getItem(key: string): string | null;
35
39
  setItem(key: string, value: string): void;
36
40
  removeItem(key: string): void;
37
41
  }
38
42
  /**
39
- * Serializes an operation per key.
40
- *
41
- * Refresh tokens rotate, so two concurrent refreshes destroy a session: the first rotates the token
42
- * and every other one fails against a token that no longer exists. In-process single-flight covers
43
- * one runtime; this covers several, which is what a serverless deployment and multiple browser tabs
44
- * need. Not offering it is why both example backends invented their own locking, differently.
43
+ * Keeps a session in `window.sessionStorage`: one tab only, gone when the tab closes. A new tab starts
44
+ * signed out.
45
45
  *
46
- * @example
47
- * ```ts
48
- * const lock: AndcoLock = {
49
- * acquire: (key, operation) => redlock.using([`andco:${key}`], 5000, operation),
50
- * };
51
- * ```
46
+ * The global is read on each call, not at construction, so building one during a server render is
47
+ * safe; only using it there fails.
52
48
  */
53
- export interface AndcoLock {
54
- acquire<T>(key: string, operation: () => Promise<T>): Promise<T>;
55
- }
56
- /** Serializes per key within one runtime. The default, and enough for a single process. */
57
- export declare class InProcessLock implements AndcoLock {
58
- #private;
59
- acquire<T>(key: string, operation: () => Promise<T>): Promise<T>;
49
+ export declare class SessionStorage implements AndcoStorage {
50
+ getItem(key: string): string | null;
51
+ setItem(key: string, value: string): void;
52
+ removeItem(key: string): void;
60
53
  }
61
54
  //# sourceMappingURL=storage.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../src/storage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC7D,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1D,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/C;AAED,2FAA2F;AAC3F,qBAAa,aAAc,YAAW,YAAY;;IAGzC,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAInC,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI;IAIzC,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;CAGrC;AAED,sGAAsG;AACtG,qBAAa,UAAW,YAAW,YAAY;IAC1B,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,OAAO;IAE7C,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAInC,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI;IAIzC,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;CAGrC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,SAAS;IACxB,OAAO,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;CAClE;AAED,2FAA2F;AAC3F,qBAAa,aAAc,YAAW,SAAS;;IAGhC,OAAO,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;CAe9E"}
1
+ {"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../src/storage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC7D,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1D,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/C;AAED,2FAA2F;AAC3F,qBAAa,aAAc,YAAW,YAAY;;IAGzC,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAInC,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI;IAIzC,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;CAGrC;AAED;;;;;;GAMG;AACH,qBAAa,YAAa,YAAW,YAAY;IACxC,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAInC,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI;IAIzC,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;CAGrC;AAED;;;;;;GAMG;AACH,qBAAa,cAAe,YAAW,YAAY;IAC1C,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAInC,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI;IAIzC,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;CAGrC"}
package/dist/storage.js CHANGED
@@ -11,37 +11,39 @@ export class MemoryStorage {
11
11
  this.#values.delete(key);
12
12
  }
13
13
  }
14
- /** Adapts a Web Storage object. Its synchronous methods satisfy the asynchronous-capable contract. */
15
- export class WebStorage {
16
- storage;
17
- constructor(storage) {
18
- this.storage = storage;
19
- }
14
+ /**
15
+ * Keeps a session in `window.localStorage`: shared by every tab of the origin and kept across browser
16
+ * restarts. The browser default.
17
+ *
18
+ * The global is read on each call, not at construction, so building one during a server render is
19
+ * safe; only using it there fails.
20
+ */
21
+ export class LocalStorage {
20
22
  getItem(key) {
21
- return this.storage.getItem(key);
23
+ return window.localStorage.getItem(key);
22
24
  }
23
25
  setItem(key, value) {
24
- this.storage.setItem(key, value);
26
+ window.localStorage.setItem(key, value);
25
27
  }
26
28
  removeItem(key) {
27
- this.storage.removeItem(key);
29
+ window.localStorage.removeItem(key);
28
30
  }
29
31
  }
30
- /** Serializes per key within one runtime. The default, and enough for a single process. */
31
- export class InProcessLock {
32
- #queues = new Map();
33
- async acquire(key, operation) {
34
- const previous = this.#queues.get(key) ?? Promise.resolve();
35
- const run = previous.then(operation, operation);
36
- // The stored link never rejects, so one failed operation cannot wedge the key for the next.
37
- const link = run.then(() => undefined, () => undefined);
38
- this.#queues.set(key, link);
39
- try {
40
- return await run;
41
- }
42
- finally {
43
- if (this.#queues.get(key) === link)
44
- this.#queues.delete(key);
45
- }
32
+ /**
33
+ * Keeps a session in `window.sessionStorage`: one tab only, gone when the tab closes. A new tab starts
34
+ * signed out.
35
+ *
36
+ * The global is read on each call, not at construction, so building one during a server render is
37
+ * safe; only using it there fails.
38
+ */
39
+ export class SessionStorage {
40
+ getItem(key) {
41
+ return window.sessionStorage.getItem(key);
42
+ }
43
+ setItem(key, value) {
44
+ window.sessionStorage.setItem(key, value);
45
+ }
46
+ removeItem(key) {
47
+ window.sessionStorage.removeItem(key);
46
48
  }
47
49
  }
@@ -0,0 +1,46 @@
1
+ import type { AndcoConfig } from "./config.js";
2
+ import { Result } from "./errors.js";
3
+ import type { AndcoGlobals } from "./globals.js";
4
+ /** Registered claims of an Andco access token, plus whatever else the Authorization Server signed. */
5
+ export type AndcoTokenClaims = {
6
+ readonly iss: string;
7
+ readonly sub?: string;
8
+ readonly aud?: string | readonly string[];
9
+ readonly exp: number;
10
+ readonly iat?: number;
11
+ readonly scope?: string;
12
+ readonly client_id?: string;
13
+ } & Readonly<Record<string, unknown>>;
14
+ /** Constraints beyond signature, `exp` and `iss`, which are always checked. */
15
+ export type AndcoTokenVerifyOptions = {
16
+ /** Resource Indicator the token must be addressed to. Omit to accept any audience. */
17
+ audience?: string;
18
+ /** Accepted clock drift in seconds. Defaults to 30. */
19
+ clockToleranceSeconds?: number;
20
+ };
21
+ /**
22
+ * Offline verification of Andco access tokens with the Authorization Server's JWKS.
23
+ *
24
+ * The JWKS URL comes from the baked Server Description, re-rooted on the configured issuer, and
25
+ * keys are cached by `kid`. An unknown `kid` triggers one (rate limited) refetch, which is how a key
26
+ * rotation is picked up without a restart. A valid signature proves who issued the token, not that
27
+ * the Grant behind it is still alive: use `grants.get` for that.
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * const { data: claims, error } = await andco.tokens.verify(bearer, { audience: "https://api.casa-norte.example" });
32
+ * if (error) return new Response(null, { status: 401 });
33
+ * ```
34
+ */
35
+ export declare class AndcoTokens {
36
+ #private;
37
+ constructor(options: {
38
+ config: AndcoConfig;
39
+ globals: AndcoGlobals;
40
+ });
41
+ /** The `iss` the Authorization Server signs: the configured base URL without a trailing slash. */
42
+ get issuer(): string;
43
+ /** Checks signature, `exp`, `iss` and, when given, `aud`. Never throws. */
44
+ verify(token: string, options?: AndcoTokenVerifyOptions): Promise<Result<AndcoTokenClaims>>;
45
+ }
46
+ //# sourceMappingURL=tokens.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../src/tokens.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,EAAc,MAAM,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAGjD,sGAAsG;AACtG,MAAM,MAAM,gBAAgB,GAAG;IAC7B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,CAAC;IAC1C,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AAEtC,+EAA+E;AAC/E,MAAM,MAAM,uBAAuB,GAAG;IACpC,sFAAsF;IACtF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,uDAAuD;IACvD,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAChC,CAAC;AAmBF;;;;;;;;;;;;;GAaG;AACH,qBAAa,WAAW;;gBAOH,OAAO,EAAE;QAAE,MAAM,EAAE,WAAW,CAAC;QAAC,OAAO,EAAE,YAAY,CAAA;KAAE;IAK1E,kGAAkG;IAClG,IAAW,MAAM,IAAI,MAAM,CAG1B;IAED,2EAA2E;IAC9D,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,GAAE,uBAA4B,GAAG,OAAO,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC;CAgF7G"}