@loopwise/admin-sdk 0.1.0-beta.2 → 0.1.0-beta.3

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,49 @@
1
1
  # @loopwise/admin-sdk
2
2
 
3
+ ## 0.1.0-beta.3
4
+
5
+ DX quick wins informed by the first real integration ([nii-course-system](https://github.com/niischool-tw/nii-course-system/pull/38),
6
+ agent-driven, shipped against `0.1.0-beta.0`). See
7
+ [TEACH-18885](https://linear.app/kaik/issue/TEACH-18885) for the full
8
+ feedback report; [TEACH-18912](https://linear.app/kaik/issue/TEACH-18912)
9
+ for this PR.
10
+
11
+ ### Breaking changes
12
+
13
+ - **`baseUrl` → `baseURL`** on both `LoopwiseAuthOptions` (better-auth
14
+ wrapper) and `LoopwiseAdminConfig` (core admin client). Casing now
15
+ matches better-auth's own `baseURL` / `BETTER_AUTH_URL` convention,
16
+ removing a same-module typo footgun. Migration is a one-character
17
+ rename at any call site that passes the option explicitly; TS
18
+ catches it at compile time. Callers relying on the default are
19
+ unaffected.
20
+
21
+ ### Features
22
+
23
+ - **`mapProfileToUser` pass-through on `loopwise({})`** — populate
24
+ custom `User` columns at sign-up (e.g. `teachifyUserId: profile.sub`)
25
+ without joining the `account` table. Forwards verbatim to
26
+ better-auth's `genericOAuth`.
27
+ - **`getLoopwiseRedirectURI({ baseURL, audience? })` helper** exported
28
+ from `@loopwise/admin-sdk/better-auth`. Returns the exact redirect
29
+ URI to whitelist on your OAuth client — the better-auth callback
30
+ path includes a `/oauth2/` segment that's easy to miss otherwise.
31
+
32
+ ### Behavior changes
33
+
34
+ - **`org_admin` default scope shrunk to `['openid', 'profile', 'email']`**
35
+ (was `['openid', 'profile', 'email', 'courses:read', 'members:read']`).
36
+ New OAuth clients don't have API scopes enabled by default, so the
37
+ old behavior caused `invalid_scope` on the first callback. Pure SSO
38
+ now works out of the box; callers needing admin GraphQL access pass
39
+ `scopes` explicitly AND enable matching scopes on their OAuth client
40
+ in the Loopwise admin UI.
41
+
42
+ ### Docs
43
+
44
+ - README adds Redirect URI, Scopes, Profile-mapping, and Roadmap
45
+ sections informed by the same feedback report.
46
+
3
47
  ## 0.1.0-beta.2
4
48
 
5
49
  Add second typed resource:
package/README.md CHANGED
@@ -55,4 +55,92 @@ export default async function Page() {
55
55
  }
56
56
  ```
57
57
 
58
- See [TEACH-18856](https://linear.app/kaik/issue/TEACH-18856) for the design rationale.
58
+ ### Redirect URI
59
+
60
+ Better Auth's catch-all handler is mounted at `/api/auth/[...all]`, but the
61
+ `genericOAuth` plugin's callback path is `/oauth2/callback/<providerId>` —
62
+ note the `oauth2/` segment (different from many OAuth tutorials that show
63
+ `/api/auth/callback/...`).
64
+
65
+ Use the exported helper to compute the exact URI to whitelist on your
66
+ OAuth client:
67
+
68
+ ```ts
69
+ import { getLoopwiseRedirectURI } from '@loopwise/admin-sdk/better-auth';
70
+
71
+ console.log(
72
+ getLoopwiseRedirectURI({ baseURL: process.env.BETTER_AUTH_URL! }),
73
+ );
74
+ // dev: http://localhost:3000/api/auth/oauth2/callback/loopwise
75
+ // prod: https://your.app/api/auth/oauth2/callback/loopwise
76
+ ```
77
+
78
+ For multi-audience apps, pass `audience: 'member'` to get
79
+ `/oauth2/callback/loopwise-member`.
80
+
81
+ ### Scopes
82
+
83
+ The wrapper requests `['openid', 'profile', 'email']` by default — the
84
+ minimum needed to identify the user. **Pure SSO works without any
85
+ additional setup.**
86
+
87
+ To call the admin GraphQL API, override `scopes` with the API scopes you
88
+ need, **and** enable those same scopes on your OAuth client in the
89
+ Loopwise admin UI:
90
+
91
+ ```ts
92
+ loopwise({
93
+ clientId, clientSecret,
94
+ scopes: ['openid', 'profile', 'email', 'courses:read', 'members:read'],
95
+ })
96
+ ```
97
+
98
+ Mismatch between requested and enabled scopes surfaces as
99
+ `error=invalid_scope` on the first callback.
100
+
101
+ ### Mapping OAuth claims to your user table
102
+
103
+ Use `mapProfileToUser` to populate custom columns (declared via
104
+ better-auth's `additionalFields`) at sign-up:
105
+
106
+ ```ts
107
+ loopwise({
108
+ clientId, clientSecret,
109
+ mapProfileToUser: (profile) => ({
110
+ teachifyUserId: profile.sub,
111
+ // role: profile['loopwise:org_role'], // pending TEACH-18913
112
+ }),
113
+ })
114
+ ```
115
+
116
+ The `profile` is whatever `/oauth/userinfo` returns — standard OIDC
117
+ claims today, plus Loopwise-specific `loopwise:*` claims once
118
+ [TEACH-18913](https://linear.app/kaik/issue/TEACH-18913) ships.
119
+
120
+ ## Typed resources roadmap
121
+
122
+ Currently typed:
123
+
124
+ - `admin.courses.list({ perPage, page })` — `AdminCoursePage`
125
+ - `admin.members.list({ perPage, page, role })` — `AdminMemberPage`
126
+
127
+ Coming next (in priority order, informed by integrator feedback):
128
+
129
+ 1. `admin.orders.list / get` — financial flow surface
130
+ 2. `admin.enrollments.list` — member ↔ course relationships
131
+ 3. Course content drill-down (`lectures`, `sections`)
132
+ 4. `admin.coupons / subscriptions / pricing`
133
+ 5. Content surfaces (`posts`, `pages`, `comments`)
134
+
135
+ Need something not yet typed? Use `admin.graphql<TData>({ query })` —
136
+ the raw escape hatch is fully typed via the generic and works against
137
+ the entire admin schema.
138
+
139
+ ## Design notes
140
+
141
+ - See [TEACH-18856](https://linear.app/kaik/issue/TEACH-18856) for the
142
+ SDK architectural rationale (why branded `loopwise()` wrapper, why
143
+ framework-agnostic core, audience model).
144
+ - See [TEACH-18885](https://linear.app/kaik/issue/TEACH-18885) for the
145
+ first-integration feedback that informed the `0.1.0-beta.3` ergonomics
146
+ (default scope, redirect URI helper, profile mapping).
@@ -20,17 +20,30 @@ interface LoopwiseAuthOptions {
20
20
  */
21
21
  audience?: LoopwiseAuthAudience;
22
22
  /**
23
- * OAuth scopes to request. Defaults to a minimal-but-useful set per
24
- * audience. Override to request more (e.g. add `courses:write`,
25
- * `payments:read`) or to narrow further.
23
+ * OAuth scopes to request. Defaults to SSO-minimum
24
+ * `['openid', 'profile', 'email']`. To call admin GraphQL, override
25
+ * with the scopes you need AND enable them on the OAuth client in
26
+ * the Loopwise admin UI (see the `AUDIENCE_PROFILES` note for why
27
+ * we don't ship API scopes by default).
26
28
  */
27
29
  scopes?: string[];
28
30
  /**
29
31
  * Base URL of the Loopwise instance. Defaults to `https://app.loopwise.com`.
30
32
  * Discovery doc fetched from
31
- * `${baseUrl}/.well-known/oauth-authorization-server`.
33
+ * `${baseURL}/.well-known/oauth-authorization-server`.
34
+ *
35
+ * Casing matches better-auth's `baseURL` / `BETTER_AUTH_URL` convention.
36
+ */
37
+ baseURL?: string;
38
+ /**
39
+ * Populate custom columns (declared via better-auth's `additionalFields`)
40
+ * at sign-up — avoids joining the `account` table to read OAuth `sub`
41
+ * or any other userinfo claim. `loopwise:org_role` lands with TEACH-18913.
42
+ *
43
+ * @example
44
+ * mapProfileToUser: (profile) => ({ teachifyUserId: profile.sub }),
32
45
  */
33
- baseUrl?: string;
46
+ mapProfileToUser?: (profile: Record<string, unknown>) => Record<string, unknown>;
34
47
  }
35
48
  /**
36
49
  * Better-auth plugin for "log in with Loopwise". Wraps `genericOAuth` with
@@ -60,6 +73,34 @@ interface LoopwiseAuthOptions {
60
73
  * }),
61
74
  * ]
62
75
  */
76
+ interface GetRedirectURIOptions {
77
+ /** Same value as better-auth's `baseURL` / `BETTER_AUTH_URL`. */
78
+ baseURL: string;
79
+ /** Defaults to `'org_admin'`. Pass `'member'` for `loopwise-member`. */
80
+ audience?: LoopwiseAuthAudience;
81
+ }
82
+ /**
83
+ * Compute the exact redirect URI you need to register on your Loopwise
84
+ * OAuth client. Better Auth's `genericOAuth` plugin uses the path
85
+ * `/api/auth/oauth2/callback/<providerId>` — note the `oauth2/` segment
86
+ * (different from many OAuth tutorials that show `/api/auth/callback/...`).
87
+ *
88
+ * @example
89
+ * // dev
90
+ * getLoopwiseRedirectURI({ baseURL: 'http://localhost:3000' });
91
+ * // → 'http://localhost:3000/api/auth/oauth2/callback/loopwise'
92
+ *
93
+ * // prod, member audience
94
+ * getLoopwiseRedirectURI({
95
+ * baseURL: 'https://app.example.com',
96
+ * audience: 'member',
97
+ * });
98
+ * // → 'https://app.example.com/api/auth/oauth2/callback/loopwise-member'
99
+ *
100
+ * Use this at app boot to print the URI so you (or your agent) can paste
101
+ * it straight into the OAuth client's redirect-URI list.
102
+ */
103
+ declare function getLoopwiseRedirectURI(options: GetRedirectURIOptions): string;
63
104
  declare function loopwise(options: LoopwiseAuthOptions): BetterAuthPlugin;
64
105
  //#endregion
65
- export { LoopwiseAuthAudience, LoopwiseAuthOptions, loopwise };
106
+ export { GetRedirectURIOptions, LoopwiseAuthAudience, LoopwiseAuthOptions, getLoopwiseRedirectURI, loopwise };
@@ -1,15 +1,14 @@
1
1
  import { genericOAuth } from "better-auth/plugins";
2
2
  //#region src/better-auth.ts
3
3
  const DEFAULT_BASE_URL = "https://app.loopwise.com";
4
+ const BETTER_AUTH_OAUTH2_CALLBACK_PATH = "/api/auth/oauth2/callback";
4
5
  const AUDIENCE_PROFILES = {
5
6
  org_admin: {
6
7
  providerId: "loopwise",
7
8
  defaultScopes: [
8
9
  "openid",
9
10
  "profile",
10
- "email",
11
- "courses:read",
12
- "members:read"
11
+ "email"
13
12
  ]
14
13
  },
15
14
  member: {
@@ -22,45 +21,43 @@ const AUDIENCE_PROFILES = {
22
21
  }
23
22
  };
24
23
  /**
25
- * Better-auth plugin for "log in with Loopwise". Wraps `genericOAuth` with
26
- * Loopwise-specific defaults so tenants only need to supply credentials.
24
+ * Compute the exact redirect URI you need to register on your Loopwise
25
+ * OAuth client. Better Auth's `genericOAuth` plugin uses the path
26
+ * `/api/auth/oauth2/callback/<providerId>` — note the `oauth2/` segment
27
+ * (different from many OAuth tutorials that show `/api/auth/callback/...`).
27
28
  *
28
29
  * @example
29
- * import { betterAuth } from 'better-auth';
30
- * import { loopwise } from '@loopwise/admin-sdk/better-auth';
30
+ * // dev
31
+ * getLoopwiseRedirectURI({ baseURL: 'http://localhost:3000' });
32
+ * // → 'http://localhost:3000/api/auth/oauth2/callback/loopwise'
31
33
  *
32
- * export const auth = betterAuth({
33
- * database: ...,
34
- * plugins: [
35
- * loopwise({
36
- * clientId: process.env.LOOPWISE_CLIENT_ID!,
37
- * clientSecret: process.env.LOOPWISE_CLIENT_SECRET!,
38
- * }),
39
- * ],
34
+ * // prod, member audience
35
+ * getLoopwiseRedirectURI({
36
+ * baseURL: 'https://app.example.com',
37
+ * audience: 'member',
40
38
  * });
39
+ * // → 'https://app.example.com/api/auth/oauth2/callback/loopwise-member'
41
40
  *
42
- * @example Multi-audience (staff + member apps coexisting)
43
- * plugins: [
44
- * loopwise({ clientId: STAFF_ID, clientSecret: STAFF_SECRET }),
45
- * loopwise({
46
- * clientId: MEMBER_ID,
47
- * clientSecret: MEMBER_SECRET,
48
- * audience: 'member',
49
- * }),
50
- * ]
41
+ * Use this at app boot to print the URI so you (or your agent) can paste
42
+ * it straight into the OAuth client's redirect-URI list.
51
43
  */
44
+ function getLoopwiseRedirectURI(options) {
45
+ const profile = AUDIENCE_PROFILES[options.audience ?? "org_admin"];
46
+ return `${options.baseURL.replace(/\/$/, "")}${BETTER_AUTH_OAUTH2_CALLBACK_PATH}/${profile.providerId}`;
47
+ }
52
48
  function loopwise(options) {
53
49
  const profile = AUDIENCE_PROFILES[options.audience ?? "org_admin"];
54
- const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
50
+ const baseURL = (options.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
55
51
  return genericOAuth({ config: [{
56
52
  providerId: profile.providerId,
57
53
  clientId: options.clientId,
58
54
  clientSecret: options.clientSecret,
59
- discoveryUrl: `${baseUrl}/.well-known/oauth-authorization-server`,
55
+ discoveryUrl: `${baseURL}/.well-known/oauth-authorization-server`,
60
56
  pkce: true,
61
57
  accessType: "offline",
62
- scopes: options.scopes ?? [...profile.defaultScopes]
58
+ scopes: options.scopes ?? [...profile.defaultScopes],
59
+ mapProfileToUser: options.mapProfileToUser
63
60
  }] });
64
61
  }
65
62
  //#endregion
66
- export { loopwise };
63
+ export { getLoopwiseRedirectURI, loopwise };
package/dist/index.d.mts CHANGED
@@ -32,12 +32,13 @@ interface LoopwiseAdminConfig {
32
32
  /**
33
33
  * Base URL of the Loopwise instance. Defaults to `https://app.loopwise.com`.
34
34
  * Override for staging (`https://staging.loopwise.com`) or self-hosted.
35
- * The SDK appends `/admin/graphql` automatically.
35
+ * The SDK appends `/admin/graphql` automatically. Casing matches
36
+ * better-auth's `baseURL` / `BETTER_AUTH_URL` convention.
36
37
  */
37
- baseUrl?: string;
38
+ baseURL?: string;
38
39
  /**
39
40
  * Override the full GraphQL endpoint URL. You normally never set this —
40
- * the SDK derives it from `baseUrl`. Use only for non-standard routing
41
+ * the SDK derives it from `baseURL`. Use only for non-standard routing
41
42
  * (proxy with path rewrite, etc.).
42
43
  */
43
44
  endpoint?: string;
package/dist/index.mjs CHANGED
@@ -32,7 +32,7 @@ var LoopwiseAdminClient = class {
32
32
  if (!config.accessToken || !config.accessToken.trim()) throw new LoopwiseError("CONFIG", "accessToken is required. Pass it to createAdminClient({ accessToken }).");
33
33
  this.accessToken = config.accessToken;
34
34
  this.refreshAccessToken = config.refreshAccessToken;
35
- this.endpoint = config.endpoint ?? resolveEndpoint(config.baseUrl);
35
+ this.endpoint = config.endpoint ?? resolveEndpoint(config.baseURL);
36
36
  if (config.fetch) this.fetchImpl = config.fetch;
37
37
  else if (typeof globalThis.fetch === "function") this.fetchImpl = globalThis.fetch.bind(globalThis);
38
38
  else throw new LoopwiseError("CONFIG", "globalThis.fetch is not available in this runtime. Pass a fetch implementation via createAdminClient({ fetch })");
@@ -139,8 +139,8 @@ var LoopwiseAdminClient = class {
139
139
  return body.data ?? null;
140
140
  }
141
141
  };
142
- function resolveEndpoint(baseUrl) {
143
- return `${(baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "")}/admin/graphql`;
142
+ function resolveEndpoint(baseURL) {
143
+ return `${(baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}/admin/graphql`;
144
144
  }
145
145
  //#endregion
146
146
  //#region src/resources/courses.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loopwise/admin-sdk",
3
- "version": "0.1.0-beta.2",
3
+ "version": "0.1.0-beta.3",
4
4
  "description": "Loopwise admin GraphQL SDK — typed access to /admin/graphql for tenant-built applications",
5
5
  "repository": {
6
6
  "type": "git",