@coswic/aipay 0.4.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # @coswic/aipay
2
2
 
3
+ ## 0.5.0 — 2026-09-08
4
+
5
+ - Add `apps callbacks list/add/remove` with the developer’s own management authorization and linked-project defaults.
6
+ - Preserve other registered callbacks, make no-op changes idempotent, and refuse to remove the final callback.
7
+ - Require server ETag support for mutations; concurrent edits and uncertain outcomes require readback instead of blind retries.
8
+ - Guide agents through developer CLI login and callback management; local `init` remains distinct from server registration.
9
+ - Allow HTTPS application callbacks in `init` and `doctor`; `test --callback` can use a separately registered loopback callback without changing project settings.
10
+ - Bound developer API requests to 30 seconds and allow conditional mutations to disable automatic retries after a 401.
11
+
3
12
  ## 0.4.0 — 2026-09-08
4
13
 
5
14
  - Simplify setup instructions around standard OAuth and HTTPS connectivity.
package/README.md CHANGED
@@ -4,7 +4,8 @@ The command-line companion to [`@coswic/aipay-sdk`](https://www.npmjs.com/packag
4
4
  Made for developers and for the coding agents they hand the Console prompt to.
5
5
 
6
6
  ```bash
7
- npx @coswic/aipay init --app <client_id> --auth <authorization server> --api <api base>
7
+ npx @coswic/aipay@latest login --api <api base>
8
+ npx @coswic/aipay@latest init --app <client_id> --auth <authorization server> --api <api base>
8
9
  npx @coswic/aipay test
9
10
  ```
10
11
 
@@ -12,6 +13,11 @@ npx @coswic/aipay test
12
13
 
13
14
  | Command | What it does |
14
15
  |---|---|
16
+ | `aipay login` / `whoami` / `logout` | Sign in as the developer to manage your Apps; on a remote machine use `login --device`. This is separate from an end user authorizing AI spending in your App. |
17
+ | `aipay apps list` / `apps create --name <name>` | List or create Apps using your developer authorization. |
18
+ | `aipay apps callbacks list --app <client_id>` | Read the registered callback URLs. In a linked project, `--app` can be omitted; `--dir` selects the project. |
19
+ | `aipay apps callbacks add --app <client_id> --callback <url>` | Add a registered callback while preserving the others. Adding an existing URL makes no write. |
20
+ | `aipay apps callbacks remove --app <client_id> --callback <url>` | Remove one callback; removing an absent URL makes no write. The final callback cannot be removed. |
15
21
  | `aipay init` | Links the project to your app: writes `.aipay/project.json` (commit it), `.env.local` (public values only, git-ignored), a start/callback route for your framework (Next.js, Express, Fastify, Hono, plain Node) or a browser helper for a Vite SPA (`--framework` overrides detection), `aipay-agent.md` (the developer docs, fetched once, for your agent to read offline) and installs the SDK. Never overwrites a file it did not write this run. `--dry-run` shows the plan. |
16
22
  | `aipay test` | The proof: a real sign-in through the AI-Pay authorization server, received by the CLI on a loopback callback, then one chat completion on that user's behalf. Prints the receipt (`request_id`, amount charged). The Console's verify step turns green from the server side. The authorization server ignores the port for `127.0.0.1` redirect URIs (RFC 8252), so the CLI binds any free port and your dev server can keep running; `--port <n>` forces one. `--no-open` prints the URL instead of opening a browser; `--model <id>` picks the model. |
17
23
  | `aipay doctor` | Checks Node, the link, `.env.local`, the SDK install, and that the authorization server and API are reachable. `--offline` skips the network. |
@@ -22,6 +28,41 @@ Every command accepts `--json` (one JSON document on stdout) and `--dir <path>`.
22
28
 
23
29
  Exit codes: `0` ok · `1` a problem you can fix locally · `2` usage · `3` network/auth.
24
30
 
31
+ ## Registering a deployed callback
32
+
33
+ ```bash
34
+ npx @coswic/aipay@latest whoami
35
+ # If signed out, or signed into another API environment:
36
+ npx @coswic/aipay@latest login --api https://api.example
37
+ npx @coswic/aipay@latest apps callbacks list --app <client_id>
38
+ npx @coswic/aipay@latest apps callbacks add --app <client_id> --callback https://yourapp.example/auth/aipay/callback
39
+ ```
40
+
41
+ `init` and `doctor` accept HTTPS application callbacks as well as HTTP loopback callbacks.
42
+ For a project configured with HTTPS, verify the hosted app in the browser or use
43
+ `aipay test --callback <registered-http-loopback-url-without-query>` without changing its configuration.
44
+
45
+ `init --callback` configures the project locally; it does not register the URL with the
46
+ server. Register the deployed callback with `apps callbacks add`, then use that same URL
47
+ in the app configuration. Keep the local callback for development. Callback commands
48
+ always use the API and authorization server associated with your developer login.
49
+ When `--app` is omitted, the linked project's `api_base` and `auth_origin` must match that
50
+ login; a mismatch blocks the command before any request, so sign in to the correct environment
51
+ first. An explicit `--app` uses the current login environment without consulting the project.
52
+ Human and JSON output include `api_base` and `auth_origin` for verification; check `whoami`
53
+ when switching environments.
54
+
55
+ Changes use the server's callback ETag in `If-Match`. If the server does not support this
56
+ precondition, the CLI refuses to change callbacks and asks for a server update. A concurrent
57
+ edit returns a conflict: list again before deciding whether to repeat your change. There
58
+ is no automatic write retry. After a timeout or an uncertain provider result, the CLI reads
59
+ back the list; readback can confirm the requested state, otherwise the outcome remains
60
+ unconfirmed. JSON output distinguishes success, no-op, conflict, and unknown outcome.
61
+
62
+ The platform's verification callback stays system-managed and is excluded from the editable
63
+ list. No client secret, owner cookie, or admin credential is needed: approve the normal CLI
64
+ developer login in your browser.
65
+
25
66
  ## Signing in from a remote machine
26
67
 
27
68
  `aipay login` opens a browser on this machine (loopback PKCE). On an SSH box, a container or
@@ -1,6 +1,10 @@
1
1
  export const AGENT_GUIDE_FILE = 'aipay-agent.md';
2
2
  export function agentGuide(input) {
3
3
  const p = input.project;
4
+ const callback = new URL(p.callback);
5
+ const verification = callback.protocol === 'https:' || callback.search
6
+ ? 'Sign in through the running app and make one chat completion whose response carries a receipt (`aipay.request_id`). Alternatively, use `aipay test --callback <registered-http-loopback-url>` with an already registered HTTP loopback URL without a query; leave the project callback unchanged.'
7
+ : '`aipay test` completes: a real sign-in through the AI-Pay authorization server and one chat completion whose response carries a receipt (`aipay.request_id`).';
4
8
  const files = input.scaffolded.length
5
9
  ? input.scaffolded.map((f) => `- \`${f}\``).join('\n')
6
10
  : '- (none — the project had them already)';
@@ -16,7 +20,7 @@ export function agentGuide(input) {
16
20
  | client_id | \`${p.app.client_id}\` (public client, PKCE S256, no secret) |
17
21
  | Authorization server | ${p.auth_origin} |
18
22
  | API base | ${p.api_base} |
19
- | Registered callback | \`${p.callback}\` (for 127.0.0.1 the port is not compared, so any local port works) |
23
+ | Configured callback (register with CLI before use) | \`${p.callback}\` (for 127.0.0.1 the port is not compared, so any local port works) |
20
24
  | Local config | \`.aipay/project.json\` (commit it), \`.env.local\` (do not commit) |
21
25
 
22
26
  Files scaffolded by the CLI (edit freely; the CLI never overwrites existing files):
@@ -26,14 +30,15 @@ ${files}
26
30
 
27
31
  1. \`aipay doctor\` reports no problems.
28
32
  2. The app's dev server serves the start route and the callback route above.
29
- 3. \`aipay test\` completes: a real sign-in through the AI-Pay authorization server and
30
- one chat completion whose response carries a receipt (\`aipay.request_id\`).
33
+ 3. ${verification}
31
34
  4. Typecheck/build pass. Do not commit automatically and do not deploy.
32
35
 
33
36
  ## Rules for agents
34
37
 
35
38
  - Read the docs below before changing the integration; they are the source of truth.
36
39
  - Use standard OAuth sign-in and app authorization.
40
+ - Manage App settings with the developer’s own CLI authorization: check \`aipay whoami\`, then run \`aipay login --api ${p.api_base}\` if needed (add \`--device\` on a remote machine). Ask the user to approve the CLI login; never substitute another App’s token.
41
+ - Use \`aipay apps callbacks list|add|remove --app ${p.app.client_id}\` to manage registered URLs. \`init --callback\` only writes local configuration; it does not register a URL. Preserve existing callbacks; on a conflict or unknown outcome, read the current list before acting again.
37
42
  - Keep OAuth tokens out of code, config files, and version control.
38
43
  - Never request \`offline_access\` unless the product needs background renewal.
39
44
  - Key user accounts on \`sub\` from the userinfo endpoint, never on email.
@@ -0,0 +1,3 @@
1
+ export declare function hasUnsafeCallbackText(value: string): boolean;
2
+ /** Application callbacks may use HTTPS; CLI test receivers remain HTTP loopback. */
3
+ export declare function parseAppCallback(callback: string): URL;
@@ -0,0 +1,14 @@
1
+ export function hasUnsafeCallbackText(value) {
2
+ return (/[\s*]/.test(value) ||
3
+ [...value].some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127));
4
+ }
5
+ /** Application callbacks may use HTTPS; CLI test receivers remain HTTP loopback. */
6
+ export function parseAppCallback(callback) {
7
+ if (!/^https?:\/\//.test(callback) || hasUnsafeCallbackText(callback))
8
+ throw new Error('callback must be an exact URL without whitespace, control characters or wildcards');
9
+ const url = new URL(callback);
10
+ const loopback = url.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(url.hostname);
11
+ if ((!loopback && url.protocol !== 'https:') || url.username || url.password || url.hash)
12
+ throw new Error('callback must be HTTPS or HTTP on 127.0.0.1/localhost, without credentials or a fragment');
13
+ return url;
14
+ }
@@ -0,0 +1,3 @@
1
+ import { type ParsedArgs } from '../args.js';
2
+ import { type Reporter } from '../output.js';
3
+ export declare function runCallbacks(args: ParsedArgs, out: Reporter, env: NodeJS.ProcessEnv): Promise<number>;
@@ -0,0 +1,304 @@
1
+ /** Owner-managed callback changes use one conditional PUT and readback, never a blind retry. */
2
+ import { resolve } from 'node:path';
3
+ import { flagString } from '../args.js';
4
+ import { createAuthStore } from '../auth-store.js';
5
+ import { hasUnsafeCallbackText } from '../callback-url.js';
6
+ import { openDeveloperApi } from '../developer-api.js';
7
+ import { CliError, EXIT } from '../output.js';
8
+ import { readProject } from '../project.js';
9
+ class CallbackError extends CliError {
10
+ publicError;
11
+ constructor(publicError, exitCode = EXIT.remote) {
12
+ super(publicError.code, publicError.message, exitCode);
13
+ this.publicError = publicError;
14
+ }
15
+ }
16
+ function record(value) {
17
+ return value && typeof value === 'object' && !Array.isArray(value)
18
+ ? value
19
+ : {};
20
+ }
21
+ function remoteError(status, body) {
22
+ const outer = record(body);
23
+ const nested = record(outer.error);
24
+ const error = Object.keys(nested).length ? nested : outer;
25
+ const details = record(error.details);
26
+ const safe = {};
27
+ for (const key of ['outcome', 'retryable', 'readback_required', 'current_redirect_uris_etag'])
28
+ if (typeof details[key] === 'string' || typeof details[key] === 'boolean')
29
+ safe[key] = details[key];
30
+ if (Array.isArray(details.current_redirect_uris) &&
31
+ details.current_redirect_uris.every((u) => typeof u === 'string'))
32
+ safe.current_redirect_uris = details.current_redirect_uris;
33
+ const code = typeof error.code === 'string'
34
+ ? error.code
35
+ : typeof outer.error === 'string'
36
+ ? outer.error
37
+ : 'remote';
38
+ return {
39
+ code,
40
+ status,
41
+ message: status === 401
42
+ ? 'The developer authorization was rejected. Run aipay login before trying again.'
43
+ : status === 409 || status === 412
44
+ ? 'The callback list changed or another update is in progress. Run aipay apps callbacks list before making another change; this update was not retried.'
45
+ : `Callback management request failed (HTTP ${status}).`,
46
+ ...(typeof error.request_id === 'string' ? { request_id: error.request_id } : {}),
47
+ ...(typeof details.outcome === 'string' ? { outcome: details.outcome } : {}),
48
+ ...(Object.keys(safe).length ? { details: safe } : {}),
49
+ };
50
+ }
51
+ function thrownError(error) {
52
+ return error instanceof CallbackError
53
+ ? error.publicError
54
+ : error instanceof CliError
55
+ ? { code: error.code, message: error.message }
56
+ : { code: 'network_error', message: 'Could not confirm the callback request with AI-Pay.' };
57
+ }
58
+ /** Preserve exact URI strings: OAuth callback matching is not URL normalization. */
59
+ function validateCallback(value) {
60
+ try {
61
+ const url = new URL(value);
62
+ if (!/^https?:\/\//.test(value) ||
63
+ hasUnsafeCallbackText(value) ||
64
+ url.username ||
65
+ url.password ||
66
+ url.hash ||
67
+ (url.protocol !== 'https:' &&
68
+ !(url.protocol === 'http:' &&
69
+ (url.hostname === '127.0.0.1' || url.hostname === 'localhost'))))
70
+ throw new Error();
71
+ }
72
+ catch {
73
+ throw new CliError('usage', '--callback must be an exact HTTPS URL or HTTP localhost/127.0.0.1 URL, without credentials, whitespace, control characters, a fragment or wildcard.', EXIT.usage);
74
+ }
75
+ }
76
+ function inputOf(args) {
77
+ const action = args.positionals[1];
78
+ if (!['list', 'add', 'remove'].includes(action ?? '') || args.positionals.length !== 2)
79
+ throw new CliError('usage', 'usage: aipay apps callbacks list|add|remove --app <id> [--callback <url>] [--dir <path>]', EXIT.usage);
80
+ for (const name of Object.keys(args.flags))
81
+ if (!['app', 'callback', 'dir', 'json', 'help'].includes(name))
82
+ throw new CliError('usage', `unknown callbacks flag --${name}`, EXIT.usage);
83
+ for (const name of ['app', 'callback', 'dir'])
84
+ if (name in args.flags && !flagString(args.flags, name))
85
+ throw new CliError('usage', `--${name} requires a value`, EXIT.usage);
86
+ const callback = flagString(args.flags, 'callback');
87
+ if (action === 'list' ? callback !== null : callback === null)
88
+ throw new CliError('usage', action === 'list' ? 'callbacks list does not accept --callback' : 'missing --callback <url>', EXIT.usage);
89
+ if (callback !== null)
90
+ validateCallback(callback);
91
+ let appId = flagString(args.flags, 'app');
92
+ let projectEnvironment;
93
+ if (!appId) {
94
+ try {
95
+ const project = readProject(resolve(flagString(args.flags, 'dir') ?? '.'));
96
+ appId = project?.app.client_id ?? null;
97
+ if (project)
98
+ projectEnvironment = { api_base: project.api_base, auth_origin: project.auth_origin };
99
+ }
100
+ catch {
101
+ throw new CliError('invalid_project', 'Cannot read a valid .aipay/project.json; pass --app <id> explicitly.', EXIT.usage);
102
+ }
103
+ }
104
+ if (!appId || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(appId))
105
+ throw new CliError('usage', 'Provide --app <app/client UUID> or a linked project with --dir <path>.', EXIT.usage);
106
+ return {
107
+ action: action,
108
+ appId,
109
+ callback,
110
+ ...(projectEnvironment ? { projectEnvironment } : {}),
111
+ };
112
+ }
113
+ /** Compare endpoints without changing either the credential destination or project files. */
114
+ function checkEnvironment(project, auth) {
115
+ if (!project)
116
+ return;
117
+ const normalized = (value) => {
118
+ try {
119
+ const url = new URL(value);
120
+ if (!['https:', 'http:'].includes(url.protocol) ||
121
+ url.username ||
122
+ url.password ||
123
+ url.search ||
124
+ url.hash)
125
+ return null;
126
+ return url.href.replace(/\/$/, '');
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ };
132
+ for (const key of ['api_base', 'auth_origin']) {
133
+ const expected = normalized(project[key]);
134
+ if (!expected || expected !== normalized(auth[key]))
135
+ throw new CallbackError({
136
+ code: 'environment_mismatch',
137
+ message: 'The linked project belongs to a different AI-Pay environment. Sign in to the correct environment first; use --app explicitly only to manage an app in the current login environment.',
138
+ outcome: 'not_attempted',
139
+ }, EXIT.problem);
140
+ }
141
+ }
142
+ async function readCallbacks(api, path, appId) {
143
+ const response = await api.request('GET', `${path}/oauth-client`);
144
+ if (response.status !== 200)
145
+ throw new CallbackError(remoteError(response.status, response.body));
146
+ const body = record(response.body);
147
+ if (!Array.isArray(body.redirect_uris) ||
148
+ !body.redirect_uris.every((u) => typeof u === 'string') ||
149
+ (body.app_id !== undefined && body.app_id !== appId) ||
150
+ (body.client_id !== undefined && body.client_id !== appId))
151
+ throw new CallbackError({
152
+ code: 'invalid_response',
153
+ message: 'AI-Pay returned an invalid callback list.',
154
+ });
155
+ const system = typeof body.test_callback_uri === 'string' ? body.test_callback_uri : undefined;
156
+ return {
157
+ redirect_uris: body.redirect_uris.filter((u) => u !== system),
158
+ ...(typeof body.redirect_uris_etag === 'string'
159
+ ? { redirect_uris_etag: body.redirect_uris_etag }
160
+ : {}),
161
+ ...(system !== undefined ? { test_callback_uri: system } : {}),
162
+ };
163
+ }
164
+ function sameUris(left, right) {
165
+ const expected = new Set(left);
166
+ const actual = new Set(right);
167
+ return expected.size === actual.size && [...expected].every((uri) => actual.has(uri));
168
+ }
169
+ async function execute(api, input) {
170
+ const path = `/v1/developer/apps/${encodeURIComponent(input.appId)}`;
171
+ const before = await readCallbacks(api, path, input.appId);
172
+ const base = { app_id: input.appId, action: input.action };
173
+ if (input.action === 'list')
174
+ return { ok: true, ...base, ...before };
175
+ if (!before.redirect_uris_etag ||
176
+ !/^"redirect-uris-[0-9a-f]{64}"$/.test(before.redirect_uris_etag))
177
+ throw new CallbackError({
178
+ code: 'conditional_update_unavailable',
179
+ message: 'This AI-Pay service does not provide a supported callback version. Update the service before changing callbacks; callbacks list remains available.',
180
+ outcome: 'not_attempted',
181
+ }, EXIT.problem);
182
+ if (input.callback === before.test_callback_uri)
183
+ throw new CallbackError({
184
+ code: 'system_callback',
185
+ message: 'This callback is managed by AI-Pay and cannot be added or removed.',
186
+ outcome: 'not_attempted',
187
+ }, EXIT.problem);
188
+ const present = before.redirect_uris.includes(input.callback);
189
+ if ((input.action === 'add' && present) || (input.action === 'remove' && !present))
190
+ return { ok: true, ...base, ...before, changed: false, outcome: 'unchanged' };
191
+ const desired = input.action === 'add'
192
+ ? [...before.redirect_uris, input.callback]
193
+ : before.redirect_uris.filter((uri) => uri !== input.callback);
194
+ if (!desired.length)
195
+ throw new CallbackError({
196
+ code: 'last_callback',
197
+ message: 'Keep at least one developer callback; the system callback does not count.',
198
+ outcome: 'not_attempted',
199
+ }, EXIT.problem);
200
+ let writeError;
201
+ try {
202
+ const response = await api.request('PUT', `${path}/redirect-uris`, {
203
+ body: { redirect_uris: desired },
204
+ headers: { 'if-match': before.redirect_uris_etag },
205
+ retryUnauthorized: false,
206
+ });
207
+ if (response.status < 200 || response.status >= 300) {
208
+ const error = remoteError(response.status, response.body);
209
+ if (response.status < 500)
210
+ throw new CallbackError({
211
+ ...error,
212
+ outcome: response.status === 412 || response.status === 409 ? 'conflict' : 'rejected',
213
+ });
214
+ writeError = error;
215
+ }
216
+ }
217
+ catch (error) {
218
+ if (error instanceof CallbackError)
219
+ throw error;
220
+ const detail = thrownError(error);
221
+ if (['unauthorized', 'expired', 'not_logged_in'].includes(detail.code))
222
+ throw new CallbackError({ ...detail, outcome: 'rejected' });
223
+ writeError = detail;
224
+ }
225
+ let observed;
226
+ try {
227
+ observed = await readCallbacks(api, path, input.appId);
228
+ }
229
+ catch (error) {
230
+ throw new CallbackError({
231
+ code: 'callback_update_unknown',
232
+ message: 'The callback update could not be verified. Run callbacks list before making another change; no update was retried.',
233
+ outcome: 'unknown',
234
+ ...(typeof writeError?.request_id === 'string' ? { request_id: writeError.request_id } : {}),
235
+ ...(writeError ? { write_error: writeError } : {}),
236
+ readback_error: thrownError(error),
237
+ });
238
+ }
239
+ if (!sameUris(desired, observed.redirect_uris))
240
+ throw new CallbackError({
241
+ code: 'callback_update_unconfirmed',
242
+ message: 'The current callback list does not match the requested list. Review callbacks list before making another change; no update was retried.',
243
+ outcome: 'unknown',
244
+ ...(typeof writeError?.request_id === 'string' ? { request_id: writeError.request_id } : {}),
245
+ ...(writeError ? { write_error: writeError } : {}),
246
+ current_redirect_uris: observed.redirect_uris,
247
+ expected_redirect_uris: desired,
248
+ });
249
+ return {
250
+ ok: true,
251
+ ...base,
252
+ ...observed,
253
+ changed: true,
254
+ outcome: 'confirmed',
255
+ verified: true,
256
+ ...(writeError ? { reconciled: true, write_error: writeError } : {}),
257
+ };
258
+ }
259
+ export async function runCallbacks(args, out, env) {
260
+ // Reject malformed invocations before loading credentials or contacting AI-Pay.
261
+ const input = inputOf(args);
262
+ let environment;
263
+ try {
264
+ const store = createAuthStore(env);
265
+ const stored = store.load();
266
+ if (stored) {
267
+ environment = { api_base: stored.api_base, auth_origin: stored.auth_origin };
268
+ // Check before openDeveloperApi can renew an expired token.
269
+ checkEnvironment(input.projectEnvironment, environment);
270
+ }
271
+ const api = await openDeveloperApi(store);
272
+ environment = { api_base: api.auth.api_base, auth_origin: api.auth.auth_origin };
273
+ // A concurrent CLI login may have replaced the stored environment between reads.
274
+ checkEnvironment(input.projectEnvironment, environment);
275
+ const result = await execute(api, input);
276
+ out.line('ok', input.action === 'list'
277
+ ? 'Registered developer callbacks'
278
+ : result.changed
279
+ ? 'Requested callback list verified.'
280
+ : 'Callback list already matches; no update needed.');
281
+ for (const uri of result.redirect_uris)
282
+ out.line('info', uri);
283
+ out.kv('api_base', environment.api_base);
284
+ out.kv('auth_origin', environment.auth_origin);
285
+ out.result({ ...result, ...environment });
286
+ return EXIT.ok;
287
+ }
288
+ catch (error) {
289
+ const detail = thrownError(error);
290
+ out.line('bad', detail.message);
291
+ if (environment) {
292
+ out.kv('api_base', environment.api_base);
293
+ out.kv('auth_origin', environment.auth_origin);
294
+ }
295
+ out.result({
296
+ ok: false,
297
+ app_id: input.appId,
298
+ action: input.action,
299
+ ...environment,
300
+ error: detail,
301
+ });
302
+ return error instanceof CliError ? error.exitCode : EXIT.remote;
303
+ }
304
+ }
@@ -10,6 +10,7 @@ import { flagString } from '../args.js';
10
10
  import { createAuthStore } from '../auth-store.js';
11
11
  import { errorMessage, openDeveloperApi } from '../developer-api.js';
12
12
  import { CliError, EXIT } from '../output.js';
13
+ import { runCallbacks } from './app-callbacks.js';
13
14
  import { DEFAULT_CALLBACK } from './init.js';
14
15
  export async function createApp(api, input) {
15
16
  const key = input.key ?? `aipay-cli-${randomUUID()}`;
@@ -23,6 +24,14 @@ export async function createApp(api, input) {
23
24
  }
24
25
  export async function runApps(args, out, env = process.env) {
25
26
  const sub = args.positionals[0] ?? 'list';
27
+ if (sub === 'callbacks')
28
+ return runCallbacks(args, out, env);
29
+ if (sub !== 'list' && sub !== 'create')
30
+ throw new CliError('usage', `unknown apps subcommand "${sub}" (list | create | callbacks)`, EXIT.usage);
31
+ if (args.positionals.length > 1)
32
+ throw new CliError('usage', 'unexpected apps positional argument', EXIT.usage);
33
+ if (sub === 'create' && !flagString(args.flags, 'name'))
34
+ throw new CliError('usage', 'missing --name <app name>', EXIT.usage);
26
35
  const store = createAuthStore(env);
27
36
  const api = await openDeveloperApi(store);
28
37
  if (sub === 'list') {
@@ -6,8 +6,8 @@
6
6
  import { existsSync, readFileSync } from 'node:fs';
7
7
  import { join, resolve } from 'node:path';
8
8
  import { flagBool, flagString } from '../args.js';
9
+ import { parseAppCallback } from '../callback-url.js';
9
10
  import { detectProject } from '../detect.js';
10
- import { parseCallback } from '../loopback.js';
11
11
  import { EXIT } from '../output.js';
12
12
  import { ENV_FILE, ENV_KEYS, readProject } from '../project.js';
13
13
  import { createRemote, docsUrl, EMPTY_404_HINT } from '../remote.js';
@@ -54,11 +54,11 @@ export async function collectChecks(dir, opts, deps) {
54
54
  text: `linked to ${project.app.name ?? project.app.client_id} (.aipay/project.json)`,
55
55
  });
56
56
  try {
57
- const t = parseCallback(project.callback);
57
+ const t = parseAppCallback(project.callback);
58
58
  checks.push({
59
59
  id: 'callback',
60
60
  level: 'ok',
61
- text: `callback ${project.callback} (loopback, port ${t.port})`,
61
+ text: `callback ${project.callback} (${t.protocol === 'https:' ? 'HTTPS' : `loopback, port ${t.port || 80}`})`,
62
62
  });
63
63
  }
64
64
  catch (e) {
@@ -12,9 +12,9 @@ import { dirname, join, resolve } from 'node:path';
12
12
  import { AGENT_GUIDE_FILE, agentGuide } from '../agent-guide.js';
13
13
  import { flagBool, flagString } from '../args.js';
14
14
  import { createAuthStore } from '../auth-store.js';
15
+ import { parseAppCallback } from '../callback-url.js';
15
16
  import { detectProject, installCommand } from '../detect.js';
16
17
  import { openDeveloperApi } from '../developer-api.js';
17
- import { parseCallback } from '../loopback.js';
18
18
  import { CliError, EXIT } from '../output.js';
19
19
  import { ENV_FILE, ENV_KEYS, FRAMEWORKS, isFramework, mergeEnvFile, mergeGitignore, readProject, VITE_ENV_KEYS, writeProject, } from '../project.js';
20
20
  import { createRemote, docsUrl, EMPTY_404_HINT } from '../remote.js';
@@ -57,7 +57,7 @@ export async function runInit(args, out, deps = {}) {
57
57
  throw new CliError('usage', `--app must be the app's client_id (a UUID), got "${clientId}"`, EXIT.usage);
58
58
  const authOrigin = originOf(flagString(args.flags, 'auth') ?? existing?.auth_origin ?? login?.auth_origin ?? null, '--auth');
59
59
  const apiBase = originOf(flagString(args.flags, 'api') ?? existing?.api_base ?? login?.api_base ?? null, '--api');
60
- const target = parseCallback(callback); // throws on non-loopback
60
+ const target = parseAppCallback(callback);
61
61
  const dryRun = flagBool(args.flags, 'dry-run');
62
62
  const install = flagBool(args.flags, 'install', true);
63
63
  const name = flagString(args.flags, 'name') ?? createdName ?? existing?.app.name ?? null;
@@ -143,7 +143,7 @@ export async function runInit(args, out, deps = {}) {
143
143
  framework: det.framework,
144
144
  typescript: det.typescript,
145
145
  srcDir: det.srcDir,
146
- callbackPath: target.path,
146
+ callbackPath: target.pathname,
147
147
  })) {
148
148
  const abs = join(dir, f.path);
149
149
  if (existsSync(abs))
@@ -216,7 +216,9 @@ export async function runInit(args, out, deps = {}) {
216
216
  }, out, env);
217
217
  out.blank();
218
218
  out.line('ok', dryRun ? 'dry run — nothing written' : `linked to app ${name ?? clientId}`);
219
- out.line('info', 'next: run `aipay test` — it receives the callback on a free loopback port, so your dev server can keep running');
219
+ out.line('info', target.protocol === 'https:' || target.search
220
+ ? 'next: verify sign-in in the running app, or use `aipay test --callback <registered-http-loopback-url>` (without a query) to verify with a local receiver; project settings stay unchanged'
221
+ : 'next: run `aipay test` — it receives the callback on a free loopback port, so your dev server can keep running');
220
222
  out.result({
221
223
  ok: installExit === null || installExit === 0,
222
224
  dry_run: dryRun,
@@ -21,7 +21,16 @@ export async function runTest(args, out, deps = {}) {
21
21
  throw new CliError('not_linked', 'this directory is not linked to an app — run `aipay init --app <client_id> ...` first', EXIT.problem);
22
22
  const timeoutS = Number(flagString(args.flags, 'timeout') ?? '300');
23
23
  const fetchImpl = fetch;
24
- const target = parseCallback(project.callback);
24
+ if ('callback' in args.flags && !flagString(args.flags, 'callback'))
25
+ throw new CliError('usage', '--callback requires a registered HTTP loopback URL', EXIT.usage);
26
+ const callback = flagString(args.flags, 'callback') ?? project.callback;
27
+ let target;
28
+ try {
29
+ target = parseCallback(callback);
30
+ }
31
+ catch {
32
+ throw new CliError('loopback_required', 'CLI test needs a registered HTTP loopback callback without query, fragment or credentials. Verify the hosted app in your browser, or pass --callback <registered-loopback-url>; the project configuration is preserved.', EXIT.usage);
33
+ }
25
34
  // 127.0.0.1: the authorization server ignores the port for loopback redirect URIs
26
35
  // (RFC 8252 §7.3), so the CLI takes any free port and your dev server can keep running.
27
36
  // localhost / explicit --port: bind exactly that port.
@@ -35,9 +44,9 @@ export async function runTest(args, out, deps = {}) {
35
44
  const redirectUri = redirectUriFor(target, loop.port);
36
45
  const cfg = { hydraPublicUrl: project.auth_origin, clientId: project.app.client_id, redirectUri };
37
46
  const { url, state, codeVerifier } = await createConnectUrl(cfg);
38
- const note = redirectUri === project.callback
47
+ const note = redirectUri === callback
39
48
  ? ''
40
- : ` (registered: ${project.callback}; the port is not compared for 127.0.0.1)`;
49
+ : ` (registered: ${callback}; the port is not compared for 127.0.0.1)`;
41
50
  out.line('info', `listening on ${redirectUri}${note}`);
42
51
  out.line('info', `sign in here (opens in your browser):\n\n ${url}\n`);
43
52
  deps.onAuthorizeUrl?.(url);
@@ -8,6 +8,7 @@ export interface DeveloperApi {
8
8
  request<T = Record<string, unknown>>(method: string, path: string, opts?: {
9
9
  body?: unknown;
10
10
  headers?: Record<string, string>;
11
+ retryUnauthorized?: boolean;
11
12
  }): Promise<ApiResult<T>>;
12
13
  }
13
14
  export declare function openDeveloperApi(store: AuthStore): Promise<DeveloperApi>;
@@ -18,6 +18,7 @@ export async function openDeveloperApi(store) {
18
18
  async request(method, path, opts = {}) {
19
19
  const call = async (a) => f(new URL(path, a.api_base), {
20
20
  method,
21
+ signal: AbortSignal.timeout(30_000),
21
22
  headers: {
22
23
  authorization: `Bearer ${a.access_token}`,
23
24
  ...(opts.body !== undefined ? { 'content-type': 'application/json' } : {}),
@@ -26,14 +27,14 @@ export async function openDeveloperApi(store) {
26
27
  ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
27
28
  });
28
29
  let res = await call(api.auth);
29
- if (res.status === 401 && api.auth.refresh_token) {
30
+ if (res.status === 401 && api.auth.refresh_token && opts.retryUnauthorized !== false) {
30
31
  api.auth = await refresh(store, api.auth, f);
31
32
  res = await call(api.auth);
32
33
  }
33
34
  const text = await res.text();
34
35
  if (isEmpty404(res.status, text))
35
36
  throw new CliError('empty_404', EMPTY_404_HINT, EXIT.remote);
36
- if (res.status === 401)
37
+ if (res.status === 401 && opts.retryUnauthorized !== false)
37
38
  throw new CliError('unauthorized', 'the API rejected the developer token — run `aipay login` again', EXIT.remote);
38
39
  let body = null;
39
40
  try {
package/dist/loopback.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * disk; the token exchange happens in the caller's memory.
6
6
  */
7
7
  import { createServer } from 'node:http';
8
+ import { hasUnsafeCallbackText } from './callback-url.js';
8
9
  /**
9
10
  * Hydra compares loopback redirect URIs per RFC 8252 §7.3: for 127.0.0.1 / [::1] the port
10
11
  * is ignored, so the CLI may receive the callback on any free port while the app keeps
@@ -20,6 +21,8 @@ export function redirectUriFor(target, port) {
20
21
  }
21
22
  export function parseCallback(callback) {
22
23
  const u = new URL(callback);
24
+ if (hasUnsafeCallbackText(callback) || u.username || u.password || u.search || u.hash)
25
+ throw new Error('CLI loopback callbacks cannot contain credentials, query, fragment, whitespace or wildcards');
23
26
  if (u.protocol !== 'http:')
24
27
  throw new Error(`callback must be http:// on a loopback host (got ${callback})`);
25
28
  const host = u.hostname;
package/dist/main.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const HELP = "aipay \u2014 AI-Pay CLI\n\nUsage\n aipay login [--api <base>] [--device] [--json] developer sign-in for the CLI itself\n aipay whoami [--json] \u00B7 aipay logout [--json]\n aipay apps list [--json]\n aipay apps create --name <name> [--callback <url>] [--json]\n aipay init --app <client_id> [--auth <origin>] [--api <base>] [--callback <url>] [--name <app name>]\n [--framework next|express|fastify|hono|vite|node] override detection\n [--dir <path>] [--dry-run] [--no-install] [--json]\n aipay init --create <app name> [--api <base>] ... create the app (needs login) and link it\n aipay test [--model <id>] [--port <n>] [--no-open] [--timeout <seconds>] [--dir <path>] [--json]\n aipay doctor [--offline] [--dir <path>] [--json]\n aipay status [--dir <path>] [--json]\n aipay unlink [--dry-run] [--dir <path>] [--json]\n\nlogin sign in as a developer (loopback PKCE, audience aipay-developer-api); tokens go to the\n OS keychain or a 0600 file \u2014 never into the project\napps list your apps / create one (Idempotency-Key: a retried create never makes a second app)\ninit link this project to an app: .aipay/project.json, .env.local, .gitignore,\n a callback route for your framework, aipay-agent.md, and the SDK install\n (--auth/--api are optional once you are signed in; --create makes the app first)\ntest real sign-in via a loopback callback (any free port on 127.0.0.1) + one chat completion \u2192 receipt\ndoctor check the link, files, SDK, and that AI-Pay is reachable\nstatus what this directory is linked to\nunlink forget the link (keeps your code)\n\nExit codes: 0 ok \u00B7 1 problem you can fix \u00B7 2 usage \u00B7 3 network/auth\n";
1
+ export declare const HELP = "aipay \u2014 AI-Pay CLI\n\nUsage\n aipay login [--api <base>] [--device] [--json] developer sign-in for the CLI itself\n aipay whoami [--json] \u00B7 aipay logout [--json]\n aipay apps list [--json]\n aipay apps create --name <name> [--callback <url>] [--json]\n aipay apps callbacks list [--app <client_id>] [--dir <path>] [--json]\n aipay apps callbacks add|remove --callback <url> [--app <client_id>] [--dir <path>] [--json]\n aipay init --app <client_id> [--auth <origin>] [--api <base>] [--callback <url>] [--name <app name>]\n [--framework next|express|fastify|hono|vite|node] override detection\n [--dir <path>] [--dry-run] [--no-install] [--json]\n aipay init --create <app name> [--api <base>] ... create the app (needs login) and link it\n aipay test [--model <id>] [--callback <registered-loopback-url>] [--port <n>] [--no-open] [--timeout <seconds>] [--dir <path>] [--json]\n aipay doctor [--offline] [--dir <path>] [--json]\n aipay status [--dir <path>] [--json]\n aipay unlink [--dry-run] [--dir <path>] [--json]\n\nlogin sign in as a developer (loopback PKCE, audience aipay-developer-api); tokens go to the\n OS keychain or a 0600 file \u2014 never into the project\napps list/create your apps; callbacks list/add/remove manages registered redirects\n callback changes preserve other URLs and reject concurrent edits\ninit link this project to an app: .aipay/project.json, .env.local, .gitignore,\n a callback route for your framework, aipay-agent.md, and the SDK install\n (--auth/--api are optional once you are signed in; --create makes the app first)\ntest real sign-in via a loopback callback (any free port on 127.0.0.1) + one chat completion \u2192 receipt\ndoctor check the link, files, SDK, and that AI-Pay is reachable\nstatus what this directory is linked to\nunlink forget the link (keeps your code)\n\nExit codes: 0 ok \u00B7 1 problem you can fix \u00B7 2 usage \u00B7 3 network/auth\n";
2
2
  export declare function main(argv: string[], io?: {
3
3
  stdout?: (s: string) => void;
4
4
  stderr?: (s: string) => void;
package/dist/main.js CHANGED
@@ -16,18 +16,21 @@ Usage
16
16
  aipay whoami [--json] · aipay logout [--json]
17
17
  aipay apps list [--json]
18
18
  aipay apps create --name <name> [--callback <url>] [--json]
19
+ aipay apps callbacks list [--app <client_id>] [--dir <path>] [--json]
20
+ aipay apps callbacks add|remove --callback <url> [--app <client_id>] [--dir <path>] [--json]
19
21
  aipay init --app <client_id> [--auth <origin>] [--api <base>] [--callback <url>] [--name <app name>]
20
22
  [--framework next|express|fastify|hono|vite|node] override detection
21
23
  [--dir <path>] [--dry-run] [--no-install] [--json]
22
24
  aipay init --create <app name> [--api <base>] ... create the app (needs login) and link it
23
- aipay test [--model <id>] [--port <n>] [--no-open] [--timeout <seconds>] [--dir <path>] [--json]
25
+ aipay test [--model <id>] [--callback <registered-loopback-url>] [--port <n>] [--no-open] [--timeout <seconds>] [--dir <path>] [--json]
24
26
  aipay doctor [--offline] [--dir <path>] [--json]
25
27
  aipay status [--dir <path>] [--json]
26
28
  aipay unlink [--dry-run] [--dir <path>] [--json]
27
29
 
28
30
  login sign in as a developer (loopback PKCE, audience aipay-developer-api); tokens go to the
29
31
  OS keychain or a 0600 file — never into the project
30
- apps list your apps / create one (Idempotency-Key: a retried create never makes a second app)
32
+ apps list/create your apps; callbacks list/add/remove manages registered redirects
33
+ callback changes preserve other URLs and reject concurrent edits
31
34
  init link this project to an app: .aipay/project.json, .env.local, .gitignore,
32
35
  a callback route for your framework, aipay-agent.md, and the SDK install
33
36
  (--auth/--api are optional once you are signed in; --create makes the app first)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@coswic/aipay",
3
- "version": "0.4.0",
4
- "description": "AI-Pay CLI — init (link an app + scaffold the integration), doctor, test (real sign-in + first receipt), status, unlink. For developers and their coding agents.",
3
+ "version": "0.5.0",
4
+ "description": "AI-Pay CLI — developer sign-in, App and callback management, project integration, diagnostics, and real sign-in + AI receipt verification.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {