@loopwise/admin-sdk 0.1.0-beta.2 → 0.1.0-beta.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,75 @@
1
1
  # @loopwise/admin-sdk
2
2
 
3
+ ## 0.1.0-beta.4
4
+
5
+ Docs-only release — surface the OIDC extension claims shipped in
6
+ [TEACH-18913](https://linear.app/kaik/issue/TEACH-18913) on the
7
+ npm-rendered README so integrators see the canonical `mapProfileToUser`
8
+ pattern without leaving the package page. No code change.
9
+
10
+ ### Docs
11
+
12
+ - **README `mapProfileToUser` example** — replaces the
13
+ `// pending TEACH-18913` placeholder with the shipped pattern that
14
+ reads `profile.org_id` (school identifier) + `profile.roles[0]` (the
15
+ highest-privilege Loopwise role) through a `ROLE_MAP` lookup, with
16
+ strict-TS-safe optional handling for the no-school-context case.
17
+ - **Edge cases documented inline** — both `org_id` and `roles` are
18
+ omitted from the userinfo response when the token has no school
19
+ context; `roles` can additionally be `[]` for a school-bound token
20
+ whose user has no roles on that school.
21
+ - **Cross-links to docs.loopwise.com** — points at the new
22
+ [AI Agent Integration guide](https://docs.loopwise.com/guides/agent-integration)
23
+ for the full cookbook (multi-school fallback via `account:read`,
24
+ role-change refresh, common mistakes).
25
+
26
+ See [TEACH-18914](https://linear.app/kaik/issue/TEACH-18914) for the
27
+ docs PR this release surfaces.
28
+
29
+ ## 0.1.0-beta.3
30
+
31
+ DX quick wins informed by the first real integration ([nii-course-system](https://github.com/niischool-tw/nii-course-system/pull/38),
32
+ agent-driven, shipped against `0.1.0-beta.0`). See
33
+ [TEACH-18885](https://linear.app/kaik/issue/TEACH-18885) for the full
34
+ feedback report; [TEACH-18912](https://linear.app/kaik/issue/TEACH-18912)
35
+ for this PR.
36
+
37
+ ### Breaking changes
38
+
39
+ - **`baseUrl` → `baseURL`** on both `LoopwiseAuthOptions` (better-auth
40
+ wrapper) and `LoopwiseAdminConfig` (core admin client). Casing now
41
+ matches better-auth's own `baseURL` / `BETTER_AUTH_URL` convention,
42
+ removing a same-module typo footgun. Migration is a one-character
43
+ rename at any call site that passes the option explicitly; TS
44
+ catches it at compile time. Callers relying on the default are
45
+ unaffected.
46
+
47
+ ### Features
48
+
49
+ - **`mapProfileToUser` pass-through on `loopwise({})`** — populate
50
+ custom `User` columns at sign-up (e.g. `teachifyUserId: profile.sub`)
51
+ without joining the `account` table. Forwards verbatim to
52
+ better-auth's `genericOAuth`.
53
+ - **`getLoopwiseRedirectURI({ baseURL, audience? })` helper** exported
54
+ from `@loopwise/admin-sdk/better-auth`. Returns the exact redirect
55
+ URI to whitelist on your OAuth client — the better-auth callback
56
+ path includes a `/oauth2/` segment that's easy to miss otherwise.
57
+
58
+ ### Behavior changes
59
+
60
+ - **`org_admin` default scope shrunk to `['openid', 'profile', 'email']`**
61
+ (was `['openid', 'profile', 'email', 'courses:read', 'members:read']`).
62
+ New OAuth clients don't have API scopes enabled by default, so the
63
+ old behavior caused `invalid_scope` on the first callback. Pure SSO
64
+ now works out of the box; callers needing admin GraphQL access pass
65
+ `scopes` explicitly AND enable matching scopes on their OAuth client
66
+ in the Loopwise admin UI.
67
+
68
+ ### Docs
69
+
70
+ - README adds Redirect URI, Scopes, Profile-mapping, and Roadmap
71
+ sections informed by the same feedback report.
72
+
3
73
  ## 0.1.0-beta.2
4
74
 
5
75
  Add second typed resource:
package/README.md CHANGED
@@ -55,4 +55,117 @@ 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. The `profile` is whatever
105
+ `/api/oauth/userinfo` returns — standard OIDC claims (`sub`, `name`,
106
+ `email`, `email_verified`) plus two Loopwise extension claims:
107
+
108
+ - `org_id` — the school the access token is bound to. Both `org_id`
109
+ and `roles` are **omitted from the response entirely** when the
110
+ token has no school context (e.g. a platform-managed personal-
111
+ access flow that hasn't picked a school yet).
112
+ - `roles` — the user's roles on that school, ordered by privilege; may
113
+ be an empty array when the token *is* school-bound but the user has
114
+ no roles on that school. When non-empty, `roles[0]` is the
115
+ highest-privilege role and is the recommended source for
116
+ onboarding-time role mapping.
117
+
118
+ ```ts
119
+ const ROLE_MAP: Record<string, string> = {
120
+ owner: 'admin',
121
+ manager: 'admin',
122
+ admin: 'admin',
123
+ teacher: 'instructor',
124
+ teaching_assistant: 'instructor',
125
+ student: 'member',
126
+ };
127
+
128
+ loopwise({
129
+ clientId, clientSecret,
130
+ mapProfileToUser: (profile) => {
131
+ const primary = (profile.roles as string[] | undefined)?.[0];
132
+ return {
133
+ teachifyUserId: profile.sub as string,
134
+ schoolId: profile.org_id as string | undefined,
135
+ role: primary ? (ROLE_MAP[primary] ?? 'member') : 'member',
136
+ };
137
+ },
138
+ })
139
+ ```
140
+
141
+ For the full claim schema, role ordering contract, and edge cases
142
+ (multi-school users, platform-managed tokens, role-change refresh),
143
+ see the [AI Agent Integration guide](https://docs.loopwise.com/guides/agent-integration).
144
+
145
+ ## Typed resources roadmap
146
+
147
+ Currently typed:
148
+
149
+ - `admin.courses.list({ perPage, page })` — `AdminCoursePage`
150
+ - `admin.members.list({ perPage, page, role })` — `AdminMemberPage`
151
+
152
+ Coming next (in priority order, informed by integrator feedback):
153
+
154
+ 1. `admin.orders.list / get` — financial flow surface
155
+ 2. `admin.enrollments.list` — member ↔ course relationships
156
+ 3. Course content drill-down (`lectures`, `sections`)
157
+ 4. `admin.coupons / subscriptions / pricing`
158
+ 5. Content surfaces (`posts`, `pages`, `comments`)
159
+
160
+ Need something not yet typed? Use `admin.graphql<TData>({ query })` —
161
+ the raw escape hatch is fully typed via the generic and works against
162
+ the entire admin schema.
163
+
164
+ ## Design notes
165
+
166
+ - See [TEACH-18856](https://linear.app/kaik/issue/TEACH-18856) for the
167
+ SDK architectural rationale (why branded `loopwise()` wrapper, why
168
+ framework-agnostic core, audience model).
169
+ - See [TEACH-18885](https://linear.app/kaik/issue/TEACH-18885) for the
170
+ first-integration feedback that informed the `0.1.0-beta.3` ergonomics
171
+ (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.4",
4
4
  "description": "Loopwise admin GraphQL SDK — typed access to /admin/graphql for tenant-built applications",
5
5
  "repository": {
6
6
  "type": "git",