@loopwise/admin-sdk 0.1.0-beta.1 → 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,61 @@
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
+
47
+ ## 0.1.0-beta.2
48
+
49
+ Add second typed resource:
50
+
51
+ - `admin.members.list({ perPage, page, role })` returns `AdminMemberPage`
52
+ (mirrors `courses.list` shape).
53
+ - `AdminMember`: `id`, `name`, `email`, `phoneNumber`, `createdAt`,
54
+ `lastSignInAt`.
55
+ - `AdminMemberRole`: `'student' | 'teaching_assistant'` mirrors the
56
+ schema enum.
57
+ - Requires OAuth scope `members:read`.
58
+
3
59
  ## 0.1.0-beta.1
4
60
 
5
61
  No functional changes. Validates the tag-triggered OIDC publish path
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;
@@ -138,9 +139,57 @@ declare class CoursesResource {
138
139
  list(args?: CoursesListArgs): Promise<AdminCoursePage>;
139
140
  }
140
141
  //#endregion
142
+ //#region src/resources/members.d.ts
143
+ /**
144
+ * "Member" = non-admin school user. Maps to `Query.users(role: ...)`,
145
+ * which returns `AdminUserPage` / `AdminUser` (students by default; TAs
146
+ * when `role: 'teaching_assistant'`). Drop down to `admin.graphql()`
147
+ * for fields outside this minimal projection (attended courses,
148
+ * subscriptions, totalSpent, etc.).
149
+ */
150
+ interface AdminMember {
151
+ id: string;
152
+ name: string | null;
153
+ email: string | null;
154
+ phoneNumber: string | null;
155
+ /** Unix timestamp seconds. */
156
+ createdAt: number;
157
+ /** Unix timestamp seconds, or null when the member has never signed in. */
158
+ lastSignInAt: number | null;
159
+ }
160
+ interface AdminMemberPage {
161
+ nodes: AdminMember[];
162
+ nodesCount: number;
163
+ currentPage: number;
164
+ totalPages: number;
165
+ hasNextPage: boolean;
166
+ hasPreviousPage: boolean;
167
+ }
168
+ type AdminMemberRole = 'student' | 'teaching_assistant';
169
+ interface MembersListArgs {
170
+ /** Items per page. Schema default 20, max 50. */
171
+ perPage?: number;
172
+ page?: number;
173
+ /** Defaults to `'student'` upstream — owner / manager are not selectable. */
174
+ role?: AdminMemberRole;
175
+ }
176
+ declare class MembersResource {
177
+ private readonly client;
178
+ constructor(client: LoopwiseAdminClient);
179
+ /**
180
+ * List non-admin school users (students by default; TAs when
181
+ * `role: 'teaching_assistant'`). Returns the full `AdminUserPage`
182
+ * shape (nodes + pagination meta) under our `AdminMemberPage` alias.
183
+ *
184
+ * Requires OAuth scope `members:read` on the access token.
185
+ */
186
+ list(args?: MembersListArgs): Promise<AdminMemberPage>;
187
+ }
188
+ //#endregion
141
189
  //#region src/index.d.ts
142
190
  interface LoopwiseAdmin {
143
191
  readonly courses: CoursesResource;
192
+ readonly members: MembersResource;
144
193
  /**
145
194
  * Run a typed GraphQL operation against `/admin/graphql`. The SDK does
146
195
  * not parse or validate the document — pass the query string as-is and
@@ -164,4 +213,4 @@ interface LoopwiseAdmin {
164
213
  }
165
214
  declare function createAdminClient(config: LoopwiseAdminConfig): LoopwiseAdmin;
166
215
  //#endregion
167
- export { type AdminCourse, type AdminCoursePage, type CoursesListArgs, type GraphQLError, type GraphQLRequest, LoopwiseAdmin, type LoopwiseAdminConfig, LoopwiseError, type LoopwiseErrorCode, createAdminClient };
216
+ export { type AdminCourse, type AdminCoursePage, type AdminMember, type AdminMemberPage, type AdminMemberRole, type CoursesListArgs, type GraphQLError, type GraphQLRequest, LoopwiseAdmin, type LoopwiseAdminConfig, LoopwiseError, type LoopwiseErrorCode, type MembersListArgs, createAdminClient };
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,12 +139,12 @@ 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
147
- const LIST_QUERY = `
147
+ const LIST_QUERY$1 = `
148
148
  query AdminCoursesList($perPage: Int, $page: Int) {
149
149
  courses(perPage: $perPage, page: $page) {
150
150
  nodes {
@@ -177,18 +177,60 @@ var CoursesResource = class {
177
177
  */
178
178
  async list(args = {}) {
179
179
  return (await this.client.graphql({
180
- query: LIST_QUERY,
180
+ query: LIST_QUERY$1,
181
181
  variables: args,
182
182
  operationName: "AdminCoursesList"
183
183
  })).courses;
184
184
  }
185
185
  };
186
186
  //#endregion
187
+ //#region src/resources/members.ts
188
+ const LIST_QUERY = `
189
+ query AdminMembersList($perPage: Int, $page: Int, $role: AdminUserRole) {
190
+ users(perPage: $perPage, page: $page, role: $role) {
191
+ nodes {
192
+ id
193
+ name
194
+ email
195
+ phoneNumber
196
+ createdAt
197
+ lastSignInAt
198
+ }
199
+ nodesCount
200
+ currentPage
201
+ totalPages
202
+ hasNextPage
203
+ hasPreviousPage
204
+ }
205
+ }
206
+ `;
207
+ var MembersResource = class {
208
+ client;
209
+ constructor(client) {
210
+ this.client = client;
211
+ }
212
+ /**
213
+ * List non-admin school users (students by default; TAs when
214
+ * `role: 'teaching_assistant'`). Returns the full `AdminUserPage`
215
+ * shape (nodes + pagination meta) under our `AdminMemberPage` alias.
216
+ *
217
+ * Requires OAuth scope `members:read` on the access token.
218
+ */
219
+ async list(args = {}) {
220
+ return (await this.client.graphql({
221
+ query: LIST_QUERY,
222
+ variables: args,
223
+ operationName: "AdminMembersList"
224
+ })).users;
225
+ }
226
+ };
227
+ //#endregion
187
228
  //#region src/index.ts
188
229
  function createAdminClient(config) {
189
230
  const client = new LoopwiseAdminClient(config);
190
231
  return {
191
232
  courses: new CoursesResource(client),
233
+ members: new MembersResource(client),
192
234
  graphql: (request) => client.graphql(request),
193
235
  setAccessToken: (token) => client.setAccessToken(token)
194
236
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loopwise/admin-sdk",
3
- "version": "0.1.0-beta.1",
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",