diff --git a/src/puter-js/src/index.js b/src/puter-js/src/index.js index b240b9bbe..8a3c98009 100644 --- a/src/puter-js/src/index.js +++ b/src/puter-js/src/index.js @@ -7,20 +7,20 @@ import localStorageMemory from './lib/polyfills/localStorage.js'; import xhrshim from './lib/polyfills/xhrshim.js'; import * as utils from './lib/utils.js'; import { AI } from './modules/ai/index.js'; -import Apps from './modules/Apps.js'; +import { Apps } from './modules/apps/index.js'; import Auth from './modules/Auth.js'; import { Debug } from './modules/Debug.js'; import Drivers from './modules/Drivers.js'; import Email from './modules/Email.js'; import { PuterJSFileSystemModule } from './modules/FileSystem/index.js'; import FSItem from './modules/FSItem.js'; -import Hosting from './modules/Hosting.js'; +import { Hosting } from './modules/hosting/index.js'; import { KV } from './modules/kv/index.js'; import { PSocket } from './modules/networking/PSocket.js'; import { PTLSSocket } from './modules/networking/PTLS.js'; import { pFetch } from './modules/networking/requests.js'; -import OS from './modules/OS.js'; -import Perms from './modules/Perms.js'; +import { OS } from './modules/os/index.js'; +import { Perms } from './modules/perms/index.js'; import PuterDialog from './modules/PuterDialog.js'; import UI from './modules/UI.js'; import Util from './modules/Util.js'; diff --git a/src/puter-js/src/lib/PuterJSError.js b/src/puter-js/src/lib/PuterJSError.js new file mode 100644 index 000000000..703a52738 --- /dev/null +++ b/src/puter-js/src/lib/PuterJSError.js @@ -0,0 +1,81 @@ +/** + * The error puter.js throws for client-side validation failures, and the + * canonical shape module code normalizes thrown values into. + * + * Historically the SDK threw one of two shapes: a plain `{ message, code }` + * object (KV, Hosting, ...) or, in a couple of Apps methods, the nested + * `{ success: false, error: { code, message } }`. `PuterJSError` is a strict + * superset of both so existing `catch` blocks keep working unchanged: + * + * - it is a real `Error`, so `err instanceof Error`, `err.stack`, and + * `err.message` all work (plain objects gave none of these); + * - `message` and `code` are own, enumerable properties, so + * `JSON.stringify(err)` and `const { code } = err` behave like the old + * plain objects did; + * - any extra fields (e.g. the legacy `success` / `error` pair) are attached + * as own properties, so callers reading `err.error.code` keep working. + * + * Error codes are API surface — they are `snake_case` and must not change. + */ +export class PuterJSError extends Error { + /** + * @param {string} message - Human-readable message. + * @param {string} [code] - Stable `snake_case` error code. + * @param {Record} [extra] - Extra own-enumerable fields + * to attach (for backward-compatible shapes such as `{ success, error }`). + */ + constructor (message, code, extra = {}) { + super(message); + + // Restore the prototype chain: subclassing the built-in Error loses it + // once the bundle is minified/transpiled, which would make both + // `instanceof PuterJSError` and `instanceof Error` return false. + Object.setPrototypeOf(this, new.target.prototype); + + // `name` is defined non-enumerable (like a native Error's) so it stays + // out of `JSON.stringify`, and as an own property so it survives + // minification of the class name. + Object.defineProperty(this, 'name', { + value: 'PuterJSError', + enumerable: false, + writable: true, + configurable: true, + }); + + // A native Error's `message` is non-enumerable; redefine it enumerable + // so the thrown value serializes and spreads like the legacy plain + // `{ message, code }` object it replaces. + Object.defineProperty(this, 'message', { + value: message, + enumerable: true, + writable: true, + configurable: true, + }); + + if ( code !== undefined ) this.code = code; + Object.assign(this, extra); + } + + /** + * Normalizes an arbitrary thrown value into a `PuterJSError`, passing an + * existing one through untouched. Object values keep their `message`, + * `code`, and every other own field; primitives become the message. + * + * @param {unknown} value + * @returns {PuterJSError} + */ + static from (value) { + if ( value instanceof PuterJSError ) return value; + + if ( value !== null && typeof value === 'object' ) { + const { message, code, ...rest } = /** @type {Record} */ (value); + return new PuterJSError( + typeof message === 'string' ? message : 'Unknown error', + typeof code === 'string' ? code : undefined, + rest, + ); + } + + return new PuterJSError(typeof value === 'string' ? value : 'Unknown error'); + } +} diff --git a/src/puter-js/src/lib/PuterJSError.test.js b/src/puter-js/src/lib/PuterJSError.test.js new file mode 100644 index 000000000..aee4332d5 --- /dev/null +++ b/src/puter-js/src/lib/PuterJSError.test.js @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { PuterJSError } from './PuterJSError.js'; + +describe('PuterJSError', () => { + it('is a real Error carrying message and code', () => { + const err = new PuterJSError('Key cannot be undefined', 'key_undefined'); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(PuterJSError); + expect(err.name).toBe('PuterJSError'); + expect(err.message).toBe('Key cannot be undefined'); + expect(err.code).toBe('key_undefined'); + expect(typeof err.stack).toBe('string'); + }); + + it('serializes and destructures like the legacy plain { message, code }', () => { + const err = new PuterJSError('bad request', 'invalid_request'); + const { message, code } = err; + expect({ message, code }).toEqual({ message: 'bad request', code: 'invalid_request' }); + expect(JSON.parse(JSON.stringify(err))).toEqual({ + message: 'bad request', + code: 'invalid_request', + }); + }); + + it('attaches extra fields for backward-compatible shapes', () => { + const err = new PuterJSError('Name is required', 'invalid_request', { + success: false, + error: { code: 'invalid_request', message: 'Name is required' }, + }); + // Both the new top-level access and the legacy nested access work. + expect(err.code).toBe('invalid_request'); + expect(err.success).toBe(false); + expect(err.error).toEqual({ code: 'invalid_request', message: 'Name is required' }); + }); + + it('omits code when none is given', () => { + const err = new PuterJSError('something happened'); + expect('code' in err).toBe(false); + expect(JSON.parse(JSON.stringify(err))).toEqual({ message: 'something happened' }); + }); + + describe('from()', () => { + it('passes an existing PuterJSError through untouched', () => { + const original = new PuterJSError('x', 'y'); + expect(PuterJSError.from(original)).toBe(original); + }); + + it('normalizes a plain { message, code } object, keeping extra fields', () => { + const err = PuterJSError.from({ message: 'nope', code: 'denied', detail: 42 }); + expect(err).toBeInstanceOf(PuterJSError); + expect(err.message).toBe('nope'); + expect(err.code).toBe('denied'); + expect(err.detail).toBe(42); + }); + + it('wraps a bare string', () => { + const err = PuterJSError.from('boom'); + expect(err.message).toBe('boom'); + expect('code' in err).toBe(false); + }); + + it('falls back to a generic message for shapeless values', () => { + expect(PuterJSError.from(undefined).message).toBe('Unknown error'); + expect(PuterJSError.from({}).message).toBe('Unknown error'); + }); + }); +}); diff --git a/src/puter-js/src/modules/Apps.js b/src/puter-js/src/modules/Apps.js deleted file mode 100644 index 5287954fc..000000000 --- a/src/puter-js/src/modules/Apps.js +++ /dev/null @@ -1,308 +0,0 @@ -import * as utils from '../lib/utils.js'; -import { fetchUrl } from '../lib/networkUtils.js'; -import { fetchAllPages, iteratePages } from '../lib/pagination.js'; - -class Apps { - /** - * Creates a new instance with the given authentication token, API origin, and app ID, - * - * @class - * @param {string} authToken - Token used to authenticate the user. - * @param {string} APIOrigin - Origin of the API server. Used to build the API endpoint URLs. - * @param {string} appID - ID of the app to use. - */ - constructor (puter) { - this.puter = puter; - this.authToken = puter.authToken; - this.APIOrigin = puter.APIOrigin; - this.appID = puter.appID; - } - - #addUserIterationToApp (app) { - app.getUsers = async (params) => { - params = params ?? {}; - return (await puter.drivers.call('app-telemetry', 'app-telemetry', 'get_users', { app_uuid: app.uid, limit: params.limit, offset: params.offset })).result; - }; - app.users = async function* (pageSize = 100) { - let offset = 0; - - while ( true ) { - const users = await app.getUsers({ limit: pageSize, offset }); - - if ( !users || users.length === 0 ) return; - - for ( const user of users ) { - yield user; - } - - offset += users.length; - if ( users.length < pageSize ) return; - } - }; - return app; - } - - #addUserIterationToApps (apps) { - apps.forEach(app => { - this.#addUserIterationToApp(app); - }); - return apps; - } - - /** - * Sets a new authentication token. - * - * @param {string} authToken - The new authentication token. - * @memberof [Apps] - * @returns {void} - */ - setAuthToken (authToken) { - this.authToken = authToken; - } - - /** - * Sets the API origin. - * - * @param {string} APIOrigin - The new API origin. - * @memberof [Apps] - * @returns {void} - */ - setAPIOrigin (APIOrigin) { - this.APIOrigin = APIOrigin; - } - - list = (...args) => { - // if args is a single object, assume it is the options object. - // Pagination keys (and `stream`) are lifted to top-level driver args; - // the rest (icon_size, stats_period, ...) stay in `params`. - const isObjectForm = typeof args[0] === 'object' && args[0] !== null; - const opts = isObjectForm ? args[0] : {}; - const { limit, offset, cursor, includeTotal, stream, ...params } = opts; - const hasCursor = Object.prototype.hasOwnProperty.call(opts, 'cursor'); - - const select = utils.make_driver_method(['uid'], 'puter-apps', 'es:app', 'select', { readonly: true }); - const base = { predicate: ['user-can-edit'] }; - if ( isObjectForm ) base.params = params; - if ( limit !== undefined ) base.limit = limit; - const fetchPage = pageParams => select.call(this, { ...base, ...pageParams }); - - if ( stream === true ) { - if ( offset !== undefined ) { - throw { message: '`offset` cannot be combined with `stream`; pass `cursor` to resume from a position.', code: 'invalid_request' }; - } - const self = this; - return (async function* () { - for await ( const page of iteratePages(fetchPage, { cursor, includeTotal: includeTotal === true }) ) { - self.#addUserIterationToApps(page.items ?? []); - yield page; - } - })(); - } - - // Any pagination param keeps the single-request behavior: a bare - // (possibly limit-capped) array, or the page envelope once the - // request opts into pagination via cursor/offset/includeTotal. - if ( limit !== undefined || offset !== undefined || hasCursor || includeTotal !== undefined ) { - return (async () => { - const options = { ...base }; - if ( offset !== undefined ) options.offset = offset; - if ( hasCursor ) options.cursor = cursor ?? null; - if ( includeTotal !== undefined ) options.includeTotal = includeTotal; - const result = await select.call(this, options); - if ( result && !Array.isArray(result) && Array.isArray(result.items) ) { - this.#addUserIterationToApps(result.items); - return result; - } - return this.#addUserIterationToApps(result); - })(); - } - - // Unbound listing: fetch page by page under the hood so no single - // request carries the whole result, then return the legacy array. - return fetchAllPages(fetchPage).then(items => this.#addUserIterationToApps(items)); - }; - - create = async (...args) => { - let options = {}; - // * allows for: puter.apps.new('example-app') * - if ( typeof args[0] === 'string' ) { - let indexURL = args[1]; - let title = args[2] ?? args[0]; - - options = { - object: { - name: args[0], - index_url: indexURL, - title: title, - }, - }; - } - - // * allows for: puter.apps.new({name: 'example-app', indexURL: 'https://example.com'}) * - else if ( typeof args[0] === 'object' && args[0] !== null ) { - let options_raw = args[0]; - - options = { - object: { - name: options_raw.name, - index_url: options_raw.indexURL, - // title is optional only if name is provided. - // If title is provided, use it. If not, use name. - title: options_raw.title ?? options_raw.name, - description: options_raw.description, - icon: options_raw.icon, - maximize_on_start: options_raw.maximizeOnStart, - background: options_raw.background, - filetype_associations: options_raw.filetypeAssociations, - metadata: options_raw.metadata, - }, - options: { - dedupe_name: options_raw.dedupeName ?? false, - }, - }; - } - - // name and indexURL are required - if ( ! options.object.name ) { - throw { - success: false, - error: { - code: 'invalid_request', - message: 'Name is required', - }, - }; - } - if ( ! options.object.index_url ) { - throw { - success: false, - error: { - code: 'invalid_request', - message: 'Index URL is required', - }, - }; - } - - // Call the original chat.complete method - return this.#addUserIterationToApp(await utils.make_driver_method(['object'], 'puter-apps', 'es:app', 'create').call(this, options)); - }; - - update = async (...args) => { - let options = {}; - - // if there is one string argument, assume it is the app name - // * allows for: puter.apps.update('example-app') * - if ( Array.isArray(args) && typeof args[0] === 'string' ) { - let object_raw = args[1]; - let object = { - name: object_raw.name, - index_url: object_raw.indexURL, - title: object_raw.title, - description: object_raw.description, - icon: object_raw.icon, - maximize_on_start: object_raw.maximizeOnStart, - background: object_raw.background, - filetype_associations: object_raw.filetypeAssociations, - metadata: object_raw.metadata, - }; - - options = { id: { name: args[0] }, object: object }; - } - - // Call the original chat.complete method - return this.#addUserIterationToApp(await utils.make_driver_method(['object'], 'puter-apps', 'es:app', 'update').call(this, options)); - }; - - get = async (...args) => { - let options = {}; - - // if there is one string argument, assume it is the app name - // * allows for: puter.apps.get('example-app') * - if ( Array.isArray(args) && typeof args[0] === 'string' ) { - // if second argument is an object, assume it is the options object - if ( typeof args[1] === 'object' && args[1] !== null ) { - options.params = args[1]; - } - // name - options.id = { name: args[0] }; - } - - // if first argument is an object, assume it is the options object - if ( typeof args[0] === 'object' && args[0] !== null ) { - options.params = args[0]; - } - return this.#addUserIterationToApp(await utils.make_driver_method(['uid'], 'puter-apps', 'es:app', 'read', { readonly: true }).call(this, options)); - }; - - delete = async (...args) => { - let options = {}; - // if there is one string argument, assume it is the app name - // * allows for: puter.apps.get('example-app') * - if ( Array.isArray(args) && typeof args[0] === 'string' ) { - options = { id: { name: args[0] } }; - } - return utils.make_driver_method(['uid'], 'puter-apps', 'es:app', 'delete').call(this, options); - }; - - checkName = async (name) => { - if ( typeof name !== 'string' || name.length === 0 ) { - throw { - success: false, - error: { - code: 'invalid_request', - message: 'Name is required', - }, - }; - } - - const resp = await fetchUrl( - `${puter.APIOrigin}/apps/nameAvailable?name=${encodeURIComponent(name)}`, - { includePuterAuth: true }, - ); - const result = await resp.json(); - if ( ! resp.ok ) { - throw result; - } - return result; - }; - - getDeveloperProfile = function (...args) { - let options; - - // If first argument is an object, it's the options - if ( typeof args[0] === 'object' && args[0] !== null ) { - options = args[0]; - } else { - // Otherwise, we assume separate arguments are provided - options = { - success: args[0], - error: args[1], - }; - } - - return new Promise((resUpper, rejUpper) => { - let options; - - // If first argument is an object, it's the options - if ( typeof args[0] === 'object' && args[0] !== null ) { - options = args[0]; - } else { - // Otherwise, we assume separate arguments are provided - options = { - success: args[0], - error: args[1], - }; - } - - return new Promise((resolve, reject) => { - const xhr = utils.initXhr('/get-dev-profile', puter.APIOrigin, puter.authToken, 'get'); - - // set up event handlers for load and error events - utils.setupXhrEventHandlers(xhr, options.success ?? resUpper, options.error ?? rejUpper, resolve, reject); - - xhr.send(); - }); - }); - }; -} - -export default Apps; diff --git a/src/puter-js/src/modules/Hosting.js b/src/puter-js/src/modules/Hosting.js deleted file mode 100644 index d7d67c45a..000000000 --- a/src/puter-js/src/modules/Hosting.js +++ /dev/null @@ -1,194 +0,0 @@ -import * as utils from '../lib/utils.js'; -import { fetchAllPages, iteratePages } from '../lib/pagination.js'; -import getAbsolutePathForApp from './FileSystem/utils/getAbsolutePathForApp.js'; - -class Hosting { - /** - * Creates a new instance with the given authentication token, API origin, and app ID, - * - * @class - * @param {string} authToken - Token used to authenticate the user. - * @param {string} APIOrigin - Origin of the API server. Used to build the API endpoint URLs. - * @param {string} appID - ID of the app to use. - */ - constructor (puter) { - this.puter = puter; - this.authToken = puter.authToken; - this.APIOrigin = puter.APIOrigin; - this.appID = puter.appID; - } - - /** - * Sets a new authentication token. - * - * @param {string} authToken - The new authentication token. - * @memberof [Router] - * @returns {void} - */ - setAuthToken (authToken) { - this.authToken = authToken; - } - - /** - * Sets the API origin. - * - * @param {string} APIOrigin - The new API origin. - * @memberof [Apps] - * @returns {void} - */ - setAPIOrigin (APIOrigin) { - this.APIOrigin = APIOrigin; - } - - // Older backends include worker-backed subdomain rows in select; - // current ones exclude them server-side. - #withoutWorkerRows (items) { - return items.filter(e => !e.subdomain.startsWith('workers.puter.')); - } - - // todo document the `Subdomain` object. - list = (...args) => { - const select = utils.make_driver_method([], 'puter-subdomains', undefined, 'select', { readonly: true }); - - const opts = (typeof args[0] === 'object' && args[0] !== null) ? args[0] : {}; - const { limit, offset, cursor, includeTotal, stream, success, error, ...rest } = opts; - const hasCursor = Object.prototype.hasOwnProperty.call(opts, 'cursor'); - - const base = { ...rest }; - if ( limit !== undefined ) base.limit = limit; - const fetchPage = pageParams => select({ ...base, ...pageParams }); - - if ( stream === true ) { - if ( offset !== undefined ) { - throw { message: '`offset` cannot be combined with `stream`; pass `cursor` to resume from a position.', code: 'invalid_request' }; - } - const self = this; - return (async function* () { - for await ( const page of iteratePages(fetchPage, { cursor, includeTotal: includeTotal === true }) ) { - yield { ...page, items: self.#withoutWorkerRows(page.items ?? []) }; - } - })(); - } - - // Any pagination param keeps the single-request behavior (envelope - // once the request opts in via cursor/includeTotal). - if ( limit !== undefined || offset !== undefined || hasCursor || includeTotal !== undefined ) { - return (async () => { - const result = await select(...args); - if ( result && !Array.isArray(result) && Array.isArray(result.items) ) { - return result; - } - return this.#withoutWorkerRows(result); - })(); - } - - // Unbound listing: fetch page by page under the hood so no single - // request carries the whole result, then return the legacy array. - const promise = fetchAllPages(fetchPage).then(items => this.#withoutWorkerRows(items)); - // Legacy callback forms: list(success, error) and list({ success, error }). - // Mirror handle_resp: the callback fires once with the full result and - // the returned promise still settles the same way. - const success_cb = typeof args[0] === 'function' ? args[0] : success; - const error_cb = typeof args[0] === 'function' ? args[1] : error; - if ( typeof success_cb === 'function' || typeof error_cb === 'function' ) { - promise.then( - result => { if ( typeof success_cb === 'function' ) success_cb(result); }, - err => { if ( typeof error_cb === 'function' ) error_cb(err); }, - ); - } - return promise; - }; - - create = async (...args) => { - let options = {}; - // * allows for: puter.hosting.new('example-subdomain') * - if ( typeof args[0] === 'string' && args.length === 1 ) { - // if subdomain is in the format of a `subdomain.puter.site` or `subdomain.puter.com`, extract the subdomain - // and use it as the subdomain. This is to make development easier. - if ( args[0].match(/^[a-z0-9]+\.puter\.(site|com)$/) ) { - args[0] = args[0].split('.')[0]; - } - - options = { object: { subdomain: args[0] } }; - } - // if there are two string arguments, assume they are the subdomain and the target directory - // * allows for: puter.hosting.new('example-subdomain', '/path/to/target') * - else if ( Array.isArray(args) && args.length === 2 && typeof args[0] === 'string' ) { - // if subdomain is in the format of a `subdomain.puter.site` or `subdomain.puter.com`, extract the subdomain - // and use it as the subdomain. This is to make development easier. - if ( args[0].match(/^[a-z0-9]+\.puter\.(site|com)$/) ) { - args[0] = args[0].split('.')[0]; - } - - // if the target directory is not an absolute path, make it an absolute path relative to the app's root directory - if ( args[1] ) { - args[1] = getAbsolutePathForApp(args[1]); - } - - options = { object: { subdomain: args[0], root_dir: args[1] } }; - } - // allows for: puter.hosting.new({ subdomain: 'subdomain' }) - else if ( typeof args[0] === 'object' ) { - options = { object: args[0] }; - } - // Call the original chat.complete method - return await utils.make_driver_method(['object'], 'puter-subdomains', undefined, 'create').call(this, options); - }; - - update = async (...args) => { - let options = {}; - // If there are two string arguments, assume they are the subdomain and the target directory - // * allows for: puter.hosting.update('example-subdomain', '/path/to/target') * - if ( Array.isArray(args) && typeof args[0] === 'string' ) { - // if subdomain is in the format of a `subdomain.puter.site` or `subdomain.puter.com`, extract the subdomain - // and use it as the subdomain. This is to make development easier. - if ( args[0].match(/^[a-z0-9]+\.puter\.(site|com)$/) ) { - args[0] = args[0].split('.')[0]; - } - - // if the target directory is not an absolute path, make it an absolute path relative to the app's root directory - if ( args[1] ) { - args[1] = getAbsolutePathForApp(args[1]); - } - - options = { id: { subdomain: args[0] }, object: { root_dir: args[1] ?? null } }; - } - - // Call the original chat.complete method - return await utils.make_driver_method(['object'], 'puter-subdomains', undefined, 'update').call(this, options); - }; - - get = async (...args) => { - let options = {}; - // if there is one string argument, assume it is the subdomain - // * allows for: puter.hosting.get('example-subdomain') * - if ( Array.isArray(args) && typeof args[0] === 'string' ) { - // if subdomain is in the format of a `subdomain.puter.site` or `subdomain.puter.com`, extract the subdomain - // and use it as the subdomain. This is to make development easier. - if ( args[0].match(/^[a-z0-9]+\.puter\.(site|com)$/) ) { - args[0] = args[0].split('.')[0]; - } - - options = { id: { subdomain: args[0] } }; - } - return utils.make_driver_method(['uid'], 'puter-subdomains', undefined, 'read', { readonly: true }).call(this, options); - }; - - delete = async (...args) => { - let options = {}; - // if there is one string argument, assume it is the subdomain - // * allows for: puter.hosting.get('example-subdomain') * - if ( Array.isArray(args) && typeof args[0] === 'string' ) { - // if subdomain is in the format of a `subdomain.puter.site` or `subdomain.puter.com`, extract the subdomain - // and use it as the subdomain. This is to make development easier. - if ( args[0].match(/^[a-z0-9]+\.puter\.(site|com)$/) ) { - args[0] = args[0].split('.')[0]; - } - - options = { id: { subdomain: args[0] } }; - } - return utils.make_driver_method(['uid'], 'puter-subdomains', undefined, 'delete').call(this, options); - }; -} - -export default Hosting; diff --git a/src/puter-js/src/modules/OS.js b/src/puter-js/src/modules/OS.js deleted file mode 100644 index c51f02dca..000000000 --- a/src/puter-js/src/modules/OS.js +++ /dev/null @@ -1,96 +0,0 @@ -import * as utils from '../lib/utils.js'; - -class OS { - /** - * Creates a new instance with the given authentication token, API origin, and app ID, - * - * @class - * @param {string} authToken - Token used to authenticate the user. - * @param {string} APIOrigin - Origin of the API server. Used to build the API endpoint URLs. - * @param {string} appID - ID of the app to use. - */ - constructor (puter) { - this.puter = puter; - this.authToken = puter.authToken; - this.APIOrigin = puter.APIOrigin; - this.appID = puter.appID; - } - - /** - * Sets a new authentication token. - * - * @param {string} authToken - The new authentication token. - * @memberof [OS] - * @returns {void} - */ - setAuthToken (authToken) { - this.authToken = authToken; - } - - /** - * Sets the API origin. - * - * @param {string} APIOrigin - The new API origin. - * @memberof [Apps] - * @returns {void} - */ - setAPIOrigin (APIOrigin) { - this.APIOrigin = APIOrigin; - } - - user = function (...args) { - let options; - - // If first argument is an object, it's the options - if ( typeof args[0] === 'object' && args[0] !== null ) { - options = args[0]; - } else { - // Otherwise, we assume separate arguments are provided - options = { - success: args[0], - error: args[1], - }; - } - - let query = ''; - if ( options?.query ) { - query = `?${ new URLSearchParams(options.query).toString()}`; - } - - return new Promise((resolve, reject) => { - const xhr = utils.initXhr(`/whoami${ query}`, this.APIOrigin, this.authToken, 'get'); - - // set up event handlers for load and error events - utils.setupXhrEventHandlers(xhr, options.success, options.error, resolve, reject); - - xhr.send(); - }); - }; - - version = function (...args) { - let options; - - // If first argument is an object, it's the options - if ( typeof args[0] === 'object' && args[0] !== null ) { - options = args[0]; - } else { - // Otherwise, we assume separate arguments are provided - options = { - success: args[0], - error: args[1], - // Add more if needed... - }; - } - - return new Promise((resolve, reject) => { - const xhr = utils.initXhr('/version', this.APIOrigin, this.authToken, 'get'); - - // set up event handlers for load and error events - utils.setupXhrEventHandlers(xhr, options.success, options.error, resolve, reject); - - xhr.send(); - }); - }; -} - -export default OS; diff --git a/src/puter-js/src/modules/Perms.js b/src/puter-js/src/modules/Perms.js deleted file mode 100644 index 3d042d9bd..000000000 --- a/src/puter-js/src/modules/Perms.js +++ /dev/null @@ -1,430 +0,0 @@ -import { fetchUrl } from '../lib/networkUtils.js'; - -export default class Perms { - constructor (puter) { - this.puter = puter; - this.authToken = puter.authToken; - this.APIOrigin = puter.APIOrigin; - } - setAuthToken (authToken) { - this.authToken = authToken; - } - setAPIOrigin (APIOrigin) { - this.APIOrigin = APIOrigin; - } - async req_ (route, body) { - try { - const resp = await fetchUrl(this.APIOrigin + route, { - method: body ? 'POST' : 'GET', - includePuterAuth: true, - headers: { - 'Content-Type': 'application/json', - }, - ...(body ? { body: JSON.stringify(body) } : {}), - }); - if ( resp.headers.get('content-type')?.includes('application/json') ) { - const jsonResult = await resp.json(); - if ( resp.status !== 200 ) { - jsonResult.error = true; - } - return jsonResult; - } - return { error: true, message: await resp.text(), code: 'unknown_error' }; - } catch (e) { - return { error: true, message: e.message, code: 'internal_error' }; - } - } - - // Grant Permissions - async grantUser (target_username, permission) { - return await this.req_('/auth/grant-user-user', { - target_username, permission, - }); - } - - async grantGroup (group_uid, permission) { - return await this.req_('/auth/grant-user-group', { - group_uid, permission, - }); - } - - async grantApp (app_uid, permission) { - return await this.req_('/auth/grant-user-app', { - app_uid, permission, - }); - } - - async grantAppAnyUser (app_uid, permission) { - return await this.req_('/auth/grant-dev-app', { - app_uid, permission, - }); - } - - async grantOrigin (origin, permission) { - return await this.req_('/auth/grant-user-app', { - origin, permission, - }); - } - - // Revoke Permissions - async revokeUser (target_username, permission) { - return await this.req_('/auth/revoke-user-user', { - target_username, permission, - }); - } - - async revokeGroup (group_uid, permission) { - return await this.req_('/auth/revoke-user-group', { - group_uid, permission, - }); - } - - async revokeApp (app_uid, permission) { - return await this.req_('/auth/revoke-user-app', { - app_uid, permission, - }); - } - - async revokeAppAnyUser (app_uid, permission) { - return await this.req_('/auth/revoke-dev-app', { - app_uid, permission, - }); - } - - async revokeOrigin (origin, permission) { - return await this.req_('/auth/revoke-user-app', { - origin, permission, - }); - } - - // Group Management - async createGroup (metadata = {}, extra = {}) { - return await this.req_('/group/create', { - metadata, extra, - }); - } - async addUsersToGroup (uid, usernames) { - return await this.req_('/group/add-users', { - uid, - users: usernames ?? [], - }); - } - async removeUsersFromGroup (uid, usernames) { - return await this.req_('/group/remove-users', { - uid, - users: usernames ?? [], - }); - } - async listGroups () { - return await this.req_('/group/list'); - } - - /** - * @deprecated use .request() instead - */ - requestPermission (...a) { - return this.request(...a); - }; - - /** - * Request a specific permission string to be granted. Note that some - * permission strings are not supported and will be denied silently. - * @param {string} permission - permission string to request - * @returns {boolean} true if permission was granted, false otherwise - */ - async request (permission) { - // note: we cannot move this fully from "puter.ui" without - // a significant refactor because the UI module contains - // all of the IPC communication logic. - return await this.puter.ui.requestPermission({ permission }); - }; - - // #region shorthand functions - /** - * Request to see a user's email. If the user has already granted this - * permission the user will not be prompted and their email address - * will be returned. If the user grants permission their email address will - * be returned. If the user does not allow access `undefined` will be - * returned. If the user does not have an email address, the value of their - * email address will be `null`. - * - * @return {string|null|undefined} An email address or undefined - */ - async requestEmail () { - let whoami; - whoami = await this.puter.auth.whoami(); - if ( whoami.email !== undefined ) return whoami.email; - const granted = await this.puter.ui.requestPermission({ - permission: `user:${whoami.uuid}:email:read`, - }); - if ( granted ) { - whoami = await this.puter.auth.whoami(); - } - return whoami.email; - } - - /** - * Request read access to the user's Desktop folder. If the user has already - * granted this permission the user will not be prompted and the path will - * be returned. If the user grants permission the path will be returned. - * If the user does not allow access `undefined` will be returned. - * - * @return {string|undefined} The Desktop path or undefined - */ - async requestReadDesktop () { - return this.requestFolder_('Desktop', 'read'); - } - - /** - * Request write access to the user's Desktop folder. If the user has already - * granted this permission the user will not be prompted and the path will - * be returned. If the user grants permission the path will be returned. - * If the user does not allow access `undefined` will be returned. - * - * @return {string|undefined} The Desktop path or undefined - */ - async requestWriteDesktop () { - return this.requestFolder_('Desktop', 'write'); - } - - /** - * Request read access to the user's Documents folder. If the user has already - * granted this permission the user will not be prompted and the path will - * be returned. If the user grants permission the path will be returned. - * If the user does not allow access `undefined` will be returned. - * - * @return {string|undefined} The Documents path or undefined - */ - async requestReadDocuments () { - return this.requestFolder_('Documents', 'read'); - } - - /** - * Request write access to the user's Documents folder. If the user has already - * granted this permission the user will not be prompted and the path will - * be returned. If the user grants permission the path will be returned. - * If the user does not allow access `undefined` will be returned. - * - * @return {string|undefined} The Documents path or undefined - */ - async requestWriteDocuments () { - return this.requestFolder_('Documents', 'write'); - } - - /** - * Request read access to the user's Pictures folder. If the user has already - * granted this permission the user will not be prompted and the path will - * be returned. If the user grants permission the path will be returned. - * If the user does not allow access `undefined` will be returned. - * - * @return {string|undefined} The Pictures path or undefined - */ - async requestReadPictures () { - return this.requestFolder_('Pictures', 'read'); - } - - /** - * Request write access to the user's Pictures folder. If the user has already - * granted this permission the user will not be prompted and the path will - * be returned. If the user grants permission the path will be returned. - * If the user does not allow access `undefined` will be returned. - * - * @return {string|undefined} The Pictures path or undefined - */ - async requestWritePictures () { - return this.requestFolder_('Pictures', 'write'); - } - - /** - * Request read access to the user's Videos folder. If the user has already - * granted this permission the user will not be prompted and the path will - * be returned. If the user grants permission the path will be returned. - * If the user does not allow access `undefined` will be returned. - * - * @return {string|undefined} The Videos path or undefined - */ - async requestReadVideos () { - return this.requestFolder_('Videos', 'read'); - } - - /** - * Request write access to the user's Videos folder. If the user has already - * granted this permission the user will not be prompted and the path will - * be returned. If the user grants permission the path will be returned. - * If the user does not allow access `undefined` will be returned. - * - * @return {string|undefined} The Videos path or undefined - */ - async requestWriteVideos () { - return this.requestFolder_('Videos', 'write'); - } - - /** - * Request read access to the user's apps. If the user has already granted - * this permission the user will not be prompted and `true` will be returned. - * If the user grants permission `true` will be returned. If the user does - * not allow access `false` will be returned. - * - * @return {boolean} Whether read access to apps was granted - */ - async requestReadApps () { - const whoami = await this.puter.auth.whoami(); - const granted = await this.puter.ui.requestPermission({ - permission: `apps-of-user:${whoami.uuid}:read`, - }); - return granted; - } - - /** - * Request write (manage) access to the user's apps. If the user has already - * granted this permission the user will not be prompted and `true` will be - * returned. If the user grants permission `true` will be returned. If the - * user does not allow access `false` will be returned. - * - * @return {boolean} Whether manage access to apps was granted - */ - async requestManageApps () { - const whoami = await this.puter.auth.whoami(); - const granted = await this.puter.ui.requestPermission({ - permission: `apps-of-user:${whoami.uuid}:write`, - }); - return granted; - } - - /** - * Request read access to the user's subdomains. If the user has already - * granted this permission the user will not be prompted and `true` will be - * returned. If the user grants permission `true` will be returned. If the - * user does not allow access `false` will be returned. - * - * @return {boolean} Whether read access to subdomains was granted - */ - async requestReadSubdomains () { - const whoami = await this.puter.auth.whoami(); - const granted = await this.puter.ui.requestPermission({ - permission: `subdomains-of-user:${whoami.uuid}:read`, - }); - return granted; - } - - /** - * Request write (manage) access to the user's subdomains. If the user has - * already granted this permission the user will not be prompted and `true` - * will be returned. If the user grants permission `true` will be returned. - * If the user does not allow access `false` will be returned. - * - * @return {boolean} Whether manage access to subdomains was granted - */ - async requestManageSubdomains () { - const whoami = await this.puter.auth.whoami(); - const granted = await this.puter.ui.requestPermission({ - permission: `subdomains-of-user:${whoami.uuid}:write`, - }); - return granted; - } - - /** - * Request read access to the root directory of one of the user's apps, - * identified by its resource request code (e.g. from app.resource_request_code). - * If the user grants permission, returns the filesystem item for that directory. - * If the user denies or an error occurs, returns undefined. - * - * @param {string} resourceRequestCode - The resource request code (e.g. `${app.uid}:root_dir`) - * @return {Promise} The directory fs item (stat) or undefined - */ - async requestReadAppRootDir (app_uid) { - return await this.#requestAppRootDir('read', app_uid); - } - - /** - * Request write access to the root directory of one of the user's apps, - * identified by its resource request code (e.g. from app.resource_request_code). - * If the user grants permission, returns the filesystem item for that directory. - * If the user denies or an error occurs, returns undefined. - * - * @param {string} resourceRequestCode - The resource request code (e.g. `${app.uid}:root_dir`) - * @return {Promise} The directory fs item (stat) or undefined - */ - async requestWriteAppRootDir (app_uid) { - return await this.#requestAppRootDir('write', app_uid); - } - - async #requestAppRootDir (access, app_uid) { - if ( typeof app_uid === 'object' && app_uid !== null ) { - app_uid = app_uid.uid; - } - if ( typeof app_uid !== 'string' ) { - throw new Error('parameter app_uid must be a strinkg'); - } - - let result; - const fetchIt = async () => result = await this.req_('/auth/request-app-root-dir', { - app_uid, - access: 'read', - }); - await fetchIt(); - if ( ! result.error ) return result; - - // Request permission - app_uid = (typeof app_or_uuid === 'object') && app_uid !== null - ? app_uid.uid : app_uid; - const readAppRootDirPermission = `app-root-dir:${app_uid}:read`; - const granted = await this.puter.ui.requestPermission({ - permission: readAppRootDirPermission, - }); - - if ( granted ) { - await fetchIt(); - - // If the server has cache invalidation issues, we still want this - // to work anyway. This is a hack, but also a reasonable safeguard. - let delay = 100; - const maxTotalWait = 5000; - let totalWaited = 0; - while ( result.error && totalWaited < maxTotalWait ) { - await new Promise(r => setTimeout(r, delay)); - totalWaited += delay; - await fetchIt(); - if ( ! result.error ) break; - delay = Math.min(delay * 2, Math.max(100, maxTotalWait - totalWaited)); - } - } - if ( ! result.error ) return result; - return undefined; - } - - /** - * Internal helper to request access to a user's special folder. - * @private - * @param {string} folderName - The name of the folder (Desktop, Documents, Pictures, Videos) - * @param {string} accessLevel - The access level (read, write) - * @return {string|undefined} The folder path or undefined - */ - async requestFolder_ (folderName, accessLevel) { - const whoami = await this.puter.auth.whoami(); - const folderPath = `/${whoami.username}/${folderName}`; - - // Check if we already have access by trying to stat the folder - try { - await this.puter.fs.stat({ path: folderPath }); - - // If we can stat the folder, we have at least read access - if ( accessLevel !== 'write' ) { - return folderPath; - } - } catch (e) { - // No access yet, need to request permission - } - - const granted = await this.puter.ui.requestPermission({ - permission: `fs:${folderPath}:${accessLevel}`, - }); - - if ( granted ) { - return folderPath; - } - - return undefined; - } - // #endregion -} diff --git a/src/puter-js/src/modules/apps/checkName.js b/src/puter-js/src/modules/apps/checkName.js new file mode 100644 index 000000000..939283c48 --- /dev/null +++ b/src/puter-js/src/modules/apps/checkName.js @@ -0,0 +1,27 @@ +import { fetchUrl } from '../../lib/networkUtils.js'; +import { invalidRequest } from './lib/validate.js'; + +/** @typedef {import('../../../types/modules/apps').CheckAppNameResult} CheckAppNameResult */ + +/** + * Checks whether an app name is available to the user. + * + * @this {import('./index.js').AppsModule} + * @param {string} name + * @returns {Promise} + */ +export async function checkName (name) { + const { puter } = this; + + if ( typeof name !== 'string' || name.length === 0 ) { + throw invalidRequest('Name is required'); + } + + const resp = await fetchUrl( + `${puter.APIOrigin}/apps/nameAvailable?name=${encodeURIComponent(name)}`, + { includePuterAuth: true }, + ); + const result = await resp.json(); + if ( ! resp.ok ) throw result; + return result; +} diff --git a/src/puter-js/src/modules/apps/create.js b/src/puter-js/src/modules/apps/create.js new file mode 100644 index 000000000..3d91eee9f --- /dev/null +++ b/src/puter-js/src/modules/apps/create.js @@ -0,0 +1,60 @@ +import * as utils from '../../lib/utils.js'; +import { addUserIteration } from './lib/appUsers.js'; +import { toAppObject } from './lib/appObject.js'; +import { invalidRequest } from './lib/validate.js'; + +/** @typedef {import('../../../types/modules/apps').CreateAppOptions} CreateAppOptions */ +/** @typedef {import('../../../types/modules/apps').CreateAppResult} CreateAppResult */ + +/** + * @overload + * @param {string} name + * @param {string} indexURL + * @param {string} [title] + * @returns {Promise} + */ +/** + * @overload + * @param {CreateAppOptions} options + * @returns {Promise} + */ +/** + * Creates a Puter app. The name must be unique to the user's apps (rejects + * otherwise) and `indexURL` must start with `http://` or `https://`. Accepts + * either positional `create(name, indexURL, title?)` or an options object. + * + * @this {import('./index.js').AppsModule} + * @param {string | CreateAppOptions} nameOrOptions + * @param {string} [indexURL] + * @param {string} [title] + * @returns {Promise} + */ +export async function create (nameOrOptions, indexURL, title) { + const { puter } = this; + + let options; + if ( typeof nameOrOptions === 'string' ) { + options = { + object: { + name: nameOrOptions, + index_url: indexURL, + // title is optional; fall back to the name when omitted. + title: title ?? nameOrOptions, + }, + }; + } else if ( typeof nameOrOptions === 'object' && nameOrOptions !== null ) { + options = { + object: { ...toAppObject(nameOrOptions), title: nameOrOptions.title ?? nameOrOptions.name }, + options: { dedupe_name: nameOrOptions.dedupeName ?? false }, + }; + } else { + options = { object: {} }; + } + + // name and indexURL are required + if ( ! options.object.name ) throw invalidRequest('Name is required'); + if ( ! options.object.index_url ) throw invalidRequest('Index URL is required'); + + const created = await utils.make_driver_method(['object'], 'puter-apps', 'es:app', 'create', { puter })(options); + return addUserIteration(puter, created); +} diff --git a/src/puter-js/src/modules/apps/delete.js b/src/puter-js/src/modules/apps/delete.js new file mode 100644 index 000000000..b5c73f1aa --- /dev/null +++ b/src/puter-js/src/modules/apps/delete.js @@ -0,0 +1,16 @@ +import * as utils from '../../lib/utils.js'; + +/** + * Deletes the app with the given name. Resolves to `{ success: true, uid }` + * with the `uid` of the deleted app. + * + * @this {import('./index.js').AppsModule} + * @param {string} name + * @returns {Promise<{ success: boolean, uid: string }>} + */ +export async function del (name) { + const { puter } = this; + + const options = typeof name === 'string' ? { id: { name } } : {}; + return await utils.make_driver_method(['uid'], 'puter-apps', 'es:app', 'delete', { puter })(options); +} diff --git a/src/puter-js/src/modules/apps/get.js b/src/puter-js/src/modules/apps/get.js new file mode 100644 index 000000000..495cd3d19 --- /dev/null +++ b/src/puter-js/src/modules/apps/get.js @@ -0,0 +1,31 @@ +import * as utils from '../../lib/utils.js'; +import { addUserIteration } from './lib/appUsers.js'; + +/** @typedef {import('../../../types/modules/apps').App} App */ +/** @typedef {import('../../../types/modules/apps').AppListOptions} AppListOptions */ + +/** + * Returns the app with the given name. Rejects if the app does not exist. + * The options object (`stats_period`, `icon_size`) may be passed as the second + * argument, or as the sole argument. + * + * @this {import('./index.js').AppsModule} + * @param {string | AppListOptions} nameOrOptions + * @param {AppListOptions} [options] + * @returns {Promise} + */ +export async function get (nameOrOptions, options) { + const { puter } = this; + + const driverArgs = {}; + if ( typeof nameOrOptions === 'string' ) { + if ( typeof options === 'object' && options !== null ) driverArgs.params = options; + driverArgs.id = { name: nameOrOptions }; + } + if ( typeof nameOrOptions === 'object' && nameOrOptions !== null ) { + driverArgs.params = nameOrOptions; + } + + const app = await utils.make_driver_method(['uid'], 'puter-apps', 'es:app', 'read', { puter, readonly: true })(driverArgs); + return addUserIteration(puter, app); +} diff --git a/src/puter-js/src/modules/apps/getDeveloperProfile.js b/src/puter-js/src/modules/apps/getDeveloperProfile.js new file mode 100644 index 000000000..1a0d33f19 --- /dev/null +++ b/src/puter-js/src/modules/apps/getDeveloperProfile.js @@ -0,0 +1,24 @@ +import * as utils from '../../lib/utils.js'; + +/** + * Fetches the caller's developer profile. Accepts either an options object + * (`{ success, error }`) or trailing positional `success`/`error` callbacks; + * either way the returned promise settles with the profile. + * + * @this {import('./index.js').AppsModule} + * @param {...(((value: Record) => void) | { success?: Function, error?: Function })} args + * @returns {Promise>} + */ +export function getDeveloperProfile (...args) { + const { puter } = this; + + const options = (typeof args[0] === 'object' && args[0] !== null) + ? args[0] + : { success: args[0], error: args[1] }; + + return new Promise((resolve, reject) => { + const xhr = utils.initXhr('/get-dev-profile', puter.APIOrigin, puter.authToken, 'get'); + utils.setupXhrEventHandlers(xhr, options.success, options.error, resolve, reject); + xhr.send(); + }); +} diff --git a/src/puter-js/src/modules/apps/index.js b/src/puter-js/src/modules/apps/index.js new file mode 100644 index 000000000..24030a15a --- /dev/null +++ b/src/puter-js/src/modules/apps/index.js @@ -0,0 +1,81 @@ +import { checkName } from './checkName.js'; +import { create } from './create.js'; +import { del } from './delete.js'; +import { get } from './get.js'; +import { getDeveloperProfile } from './getDeveloperProfile.js'; +import { list } from './list.js'; +import { update } from './update.js'; + +/** @typedef {import('../../../types/puter').Puter} Puter */ + +/** + * The `puter.apps` module. Holds a reference to the owning Puter instance and + * reads auth state from it live — nothing is copied out, so token and origin + * changes on the instance apply to in-flight modules immediately. + * + * Method implementations live in the sibling files as `this`-context + * functions whose JSDoc (including the per-form `@overload` declarations) is + * the source of truth for the public signatures; types/modules/apps.d.ts + * mirrors them for TypeScript consumers of the published SDK. + */ +export class AppsModule { + /** @type {Puter} */ + puter; + + // The fields hold the unbound functions so they keep the full overloaded + // types (`bind` erases overloads); the constructor rebinds them at runtime + // so destructured calls (`const { create } = puter.apps`) keep working. + list = list; + create = create; + update = update; + get = get; + delete = del; + checkName = checkName; + getDeveloperProfile = getDeveloperProfile; + + /** @param {Puter} puter */ + constructor (puter) { + this.puter = puter; + + const methods = /** @type {Record unknown>} */ ( + /** @type {unknown} */ (this) + ); + for ( const name of ['list', 'create', 'update', 'get', 'delete', 'checkName', 'getDeveloperProfile'] ) { + methods[name] = methods[name].bind(this); + } + } + + // Kept for backward compatibility: these used to be copied fields kept in + // sync by set{AuthToken,APIOrigin}; they now read through live. + get authToken () { + return this.puter.authToken; + } + + get APIOrigin () { + return this.puter.APIOrigin; + } + + get appID () { + return this.puter.appID; + } + + // No-ops: auth state is read from the Puter instance at call time. The + // module registry still invokes these on token/origin changes. + setAuthToken () {} + + setAPIOrigin () {} +} + +/** + * The public face of the module: derived from the class, with the internal + * `puter` handle and the legacy `authToken` accessor omitted. + * + * @typedef {import('../../lib/types.js').OmitMembers< + * typeof AppsModule, + * 'puter' | 'authToken' + * >} AppsConstructor + */ + +export const Apps = /** @type {AppsConstructor} */ (AppsModule); + +export default Apps; diff --git a/src/puter-js/src/modules/apps/lib/appObject.js b/src/puter-js/src/modules/apps/lib/appObject.js new file mode 100644 index 000000000..c91035e16 --- /dev/null +++ b/src/puter-js/src/modules/apps/lib/appObject.js @@ -0,0 +1,29 @@ +// Shared field remapping for the puter.apps methods: the public options use +// camelCase (indexURL, maximizeOnStart, ...) while the `puter-apps` driver +// expects snake_case. create() and update() share this so the field list +// lives in exactly one place. + +/** + * @typedef {import('../../../../types/modules/apps').CreateAppOptions + * | import('../../../../types/modules/apps').UpdateAppAttributes} AppAttributes + */ + +/** + * Maps the camelCase public app attributes to the snake_case `object` the + * driver stores. `title` is passed through as-is; create() overlays its + * name-fallback default on top. + * + * @param {AppAttributes} raw + * @returns {Record} + */ +export const toAppObject = (raw) => ({ + name: raw.name, + index_url: raw.indexURL, + title: raw.title, + description: raw.description, + icon: raw.icon, + maximize_on_start: raw.maximizeOnStart, + background: raw.background, + filetype_associations: raw.filetypeAssociations, + metadata: raw.metadata, +}); diff --git a/src/puter-js/src/modules/apps/lib/appUsers.js b/src/puter-js/src/modules/apps/lib/appUsers.js new file mode 100644 index 000000000..53991af18 --- /dev/null +++ b/src/puter-js/src/modules/apps/lib/appUsers.js @@ -0,0 +1,45 @@ +// Augments returned `App` objects with the `getUsers()` page fetcher and the +// `users()` async iterator, backed by the app-telemetry driver. Shared by +// every method that returns apps (create/update/get/list). + +/** @typedef {import('../../../../types/puter').Puter} Puter */ +/** @typedef {import('../../../../types/modules/apps').App} App */ + +/** + * @param {Puter} puter + * @param {App} app + * @returns {App} + */ +export const addUserIteration = (puter, app) => { + app.getUsers = async (params) => { + params = params ?? {}; + return (await puter.drivers.call('app-telemetry', 'app-telemetry', 'get_users', { + app_uuid: app.uid, + limit: params.limit, + offset: params.offset, + })).result; + }; + + app.users = async function* (pageSize = 100) { + let offset = 0; + while ( true ) { + const users = await app.getUsers({ limit: pageSize, offset }); + if ( !users || users.length === 0 ) return; + for ( const user of users ) yield user; + offset += users.length; + if ( users.length < pageSize ) return; + } + }; + + return app; +}; + +/** + * @param {Puter} puter + * @param {App[]} apps + * @returns {App[]} + */ +export const addUserIterationToApps = (puter, apps) => { + apps.forEach(app => addUserIteration(puter, app)); + return apps; +}; diff --git a/src/puter-js/src/modules/apps/lib/validate.js b/src/puter-js/src/modules/apps/lib/validate.js new file mode 100644 index 000000000..c49b7ef47 --- /dev/null +++ b/src/puter-js/src/modules/apps/lib/validate.js @@ -0,0 +1,16 @@ +import { PuterJSError } from '../../../lib/PuterJSError.js'; + +// puter.apps has always thrown a nested `{ success: false, error: { code, +// message } }` for client-side validation failures. PuterJSError keeps that +// exact shape (via the extra fields) while also exposing top-level +// `message`/`code`, so both `err.error.code` and `err.code` keep working. + +/** + * @param {string} message + * @returns {PuterJSError} + */ +export const invalidRequest = (message) => + new PuterJSError(message, 'invalid_request', { + success: false, + error: { code: 'invalid_request', message }, + }); diff --git a/src/puter-js/src/modules/apps/list.js b/src/puter-js/src/modules/apps/list.js new file mode 100644 index 000000000..7a5bff09d --- /dev/null +++ b/src/puter-js/src/modules/apps/list.js @@ -0,0 +1,85 @@ +import * as utils from '../../lib/utils.js'; +import { fetchAllPages, iteratePages } from '../../lib/pagination.js'; +import { PuterJSError } from '../../lib/PuterJSError.js'; +import { addUserIterationToApps } from './lib/appUsers.js'; + +/** @typedef {import('../../../types/modules/apps').App} App */ +/** @typedef {import('../../../types/modules/apps').AppListOptions} AppListOptions */ + +/** + * @overload + * @param {AppListOptions & import('../../../types/shared').ListStreamOptions} options + * @returns {AsyncIterableIterator>} + */ +/** + * @overload + * @param {AppListOptions & import('../../../types/shared').ListPaginationOptions & ({ cursor: string | null } | { offset: number } | { includeTotal: true })} options + * @returns {Promise>} + */ +/** + * @overload + * @param {AppListOptions & { limit?: number }} [options] + * @returns {Promise} + */ +/** + * Returns the apps the caller can access, fetching page by page under the hood + * and resolving to a plain array. Non-pagination options (`stats_period`, + * `icon_size`) are forwarded as `params`. Any pagination param + * (`cursor`/`offset`/`includeTotal`) switches to a single-request page + * envelope, and `stream: true` returns an async iterator of page envelopes. + * + * @this {import('./index.js').AppsModule} + * @param {AppListOptions & (import('../../../types/shared').ListPaginationOptions | import('../../../types/shared').ListStreamOptions)} [options] + * @returns {Promise | Promise> | AsyncIterableIterator>} + */ +export function list (options) { + const { puter } = this; + + const isObjectForm = typeof options === 'object' && options !== null; + const opts = isObjectForm ? options : {}; + const { limit, offset, cursor, includeTotal, stream, ...params } = opts; + const hasCursor = Object.prototype.hasOwnProperty.call(opts, 'cursor'); + + const select = utils.make_driver_method(['uid'], 'puter-apps', 'es:app', 'select', { puter, readonly: true }); + const base = { predicate: ['user-can-edit'] }; + if ( isObjectForm ) base.params = params; + if ( limit !== undefined ) base.limit = limit; + const fetchPage = pageParams => select({ ...base, ...pageParams }); + + if ( stream === true ) { + if ( offset !== undefined ) { + throw new PuterJSError( + '`offset` cannot be combined with `stream`; pass `cursor` to resume from a position.', + 'invalid_request', + ); + } + return (async function* () { + for await ( const page of iteratePages(fetchPage, { cursor, includeTotal: includeTotal === true }) ) { + addUserIterationToApps(puter, page.items ?? []); + yield page; + } + })(); + } + + // Any pagination param keeps the single-request behavior: a bare (possibly + // limit-capped) array, or the page envelope once the request opts into + // pagination via cursor/offset/includeTotal. + if ( limit !== undefined || offset !== undefined || hasCursor || includeTotal !== undefined ) { + return (async () => { + const driverArgs = { ...base }; + if ( offset !== undefined ) driverArgs.offset = offset; + if ( hasCursor ) driverArgs.cursor = cursor ?? null; + if ( includeTotal !== undefined ) driverArgs.includeTotal = includeTotal; + const result = await select(driverArgs); + if ( result && !Array.isArray(result) && Array.isArray(result.items) ) { + addUserIterationToApps(puter, result.items); + return result; + } + return addUserIterationToApps(puter, result); + })(); + } + + // Unbound listing: fetch page by page under the hood so no single request + // carries the whole result, then return the legacy array. + return fetchAllPages(fetchPage).then(items => addUserIterationToApps(puter, items)); +} diff --git a/src/puter-js/src/modules/apps/update.js b/src/puter-js/src/modules/apps/update.js new file mode 100644 index 000000000..22cea8814 --- /dev/null +++ b/src/puter-js/src/modules/apps/update.js @@ -0,0 +1,26 @@ +import * as utils from '../../lib/utils.js'; +import { addUserIteration } from './lib/appUsers.js'; +import { toAppObject } from './lib/appObject.js'; + +/** @typedef {import('../../../types/modules/apps').App} App */ +/** @typedef {import('../../../types/modules/apps').UpdateAppAttributes} UpdateAppAttributes */ + +/** + * Updates attributes of the app with the given name. + * + * @this {import('./index.js').AppsModule} + * @param {string} name + * @param {UpdateAppAttributes} attributes + * @returns {Promise} + */ +export async function update (name, attributes) { + const { puter } = this; + + let options = {}; + if ( typeof name === 'string' ) { + options = { id: { name }, object: toAppObject(attributes ?? {}) }; + } + + const updated = await utils.make_driver_method(['object'], 'puter-apps', 'es:app', 'update', { puter })(options); + return addUserIteration(puter, updated); +} diff --git a/src/puter-js/src/modules/hosting/create.js b/src/puter-js/src/modules/hosting/create.js new file mode 100644 index 000000000..82cc55aab --- /dev/null +++ b/src/puter-js/src/modules/hosting/create.js @@ -0,0 +1,45 @@ +import * as utils from '../../lib/utils.js'; +import getAbsolutePathForApp from '../FileSystem/utils/getAbsolutePathForApp.js'; +import { normalizeSubdomain } from './lib/args.js'; + +/** @typedef {import('../../../types/modules/hosting').Subdomain} Subdomain */ + +/** + * @overload + * @param {string} subdomain + * @param {string} dirPath + * @returns {Promise} + */ +/** + * @overload + * @param {{ subdomain: string, root_dir: string }} options + * @returns {Promise} + */ +/** + * Creates a new subdomain served from the given directory. Rejects if a + * subdomain with that name already exists or the path does not exist. + * + * Accepts `create(subdomain)`, `create(subdomain, dirPath)`, or the object + * form `create({ subdomain, root_dir })`. A full host (`sub.puter.site`) is + * accepted in place of a bare subdomain and stripped to its label. + * + * @this {import('./index.js').HostingModule} + * @param {string | { subdomain: string, root_dir?: string }} subdomainOrObject + * @param {string} [dirPath] + * @returns {Promise} + */ +export async function create (subdomainOrObject, dirPath) { + const { puter } = this; + + let object; + if ( typeof subdomainOrObject === 'string' ) { + const subdomain = normalizeSubdomain(subdomainOrObject); + object = dirPath !== undefined + ? { subdomain, root_dir: dirPath ? getAbsolutePathForApp(dirPath) : dirPath } + : { subdomain }; + } else { + object = subdomainOrObject; + } + + return await utils.make_driver_method(['object'], 'puter-subdomains', undefined, 'create', { puter })({ object }); +} diff --git a/src/puter-js/src/modules/hosting/delete.js b/src/puter-js/src/modules/hosting/delete.js new file mode 100644 index 000000000..d13ccec96 --- /dev/null +++ b/src/puter-js/src/modules/hosting/delete.js @@ -0,0 +1,21 @@ +import * as utils from '../../lib/utils.js'; +import { normalizeSubdomain } from './lib/args.js'; + +/** + * Deletes a subdomain from the account; it is no longer served. The associated + * directory is disconnected but not deleted. Rejects if the subdomain does not + * exist. + * + * @this {import('./index.js').HostingModule} + * @param {string} subdomain + * @returns {Promise<{ success: boolean, uid: string }>} + */ +export async function del (subdomain) { + const { puter } = this; + + const options = typeof subdomain === 'string' + ? { id: { subdomain: normalizeSubdomain(subdomain) } } + : {}; + + return await utils.make_driver_method(['uid'], 'puter-subdomains', undefined, 'delete', { puter })(options); +} diff --git a/src/puter-js/src/modules/hosting/get.js b/src/puter-js/src/modules/hosting/get.js new file mode 100644 index 000000000..5c19ca85e --- /dev/null +++ b/src/puter-js/src/modules/hosting/get.js @@ -0,0 +1,21 @@ +import * as utils from '../../lib/utils.js'; +import { normalizeSubdomain } from './lib/args.js'; + +/** @typedef {import('../../../types/modules/hosting').Subdomain} Subdomain */ + +/** + * Retrieves a subdomain by name. Rejects if the subdomain does not exist. + * + * @this {import('./index.js').HostingModule} + * @param {string} subdomain + * @returns {Promise} + */ +export async function get (subdomain) { + const { puter } = this; + + const options = typeof subdomain === 'string' + ? { id: { subdomain: normalizeSubdomain(subdomain) } } + : {}; + + return await utils.make_driver_method(['uid'], 'puter-subdomains', undefined, 'read', { puter, readonly: true })(options); +} diff --git a/src/puter-js/src/modules/hosting/index.js b/src/puter-js/src/modules/hosting/index.js new file mode 100644 index 000000000..99669a476 --- /dev/null +++ b/src/puter-js/src/modules/hosting/index.js @@ -0,0 +1,77 @@ +import { create } from './create.js'; +import { del } from './delete.js'; +import { get } from './get.js'; +import { list } from './list.js'; +import { update } from './update.js'; + +/** @typedef {import('../../../types/puter').Puter} Puter */ + +/** + * The `puter.hosting` module. Holds a reference to the owning Puter instance + * and reads auth state from it live — nothing is copied out, so token and + * origin changes on the instance apply to in-flight modules immediately. + * + * Method implementations live in the sibling files as `this`-context + * functions whose JSDoc (including the per-form `@overload` declarations) is + * the source of truth for the public signatures; types/modules/hosting.d.ts + * mirrors them for TypeScript consumers of the published SDK. + */ +export class HostingModule { + /** @type {Puter} */ + puter; + + // The fields hold the unbound functions so they keep the full overloaded + // types (`bind` erases overloads); the constructor rebinds them at runtime + // so destructured calls (`const { create } = puter.hosting`) keep working. + list = list; + create = create; + update = update; + get = get; + delete = del; + + /** @param {Puter} puter */ + constructor (puter) { + this.puter = puter; + + const methods = /** @type {Record unknown>} */ ( + /** @type {unknown} */ (this) + ); + for ( const name of ['list', 'create', 'update', 'get', 'delete'] ) { + methods[name] = methods[name].bind(this); + } + } + + // Kept for backward compatibility: these used to be copied fields kept in + // sync by set{AuthToken,APIOrigin}; they now read through live. + get authToken () { + return this.puter.authToken; + } + + get APIOrigin () { + return this.puter.APIOrigin; + } + + get appID () { + return this.puter.appID; + } + + // No-ops: auth state is read from the Puter instance at call time. The + // module registry still invokes these on token/origin changes. + setAuthToken () {} + + setAPIOrigin () {} +} + +/** + * The public face of the module: derived from the class, with the internal + * `puter` handle and the legacy `authToken` accessor omitted. + * + * @typedef {import('../../lib/types.js').OmitMembers< + * typeof HostingModule, + * 'puter' | 'authToken' + * >} HostingConstructor + */ + +export const Hosting = /** @type {HostingConstructor} */ (HostingModule); + +export default Hosting; diff --git a/src/puter-js/src/modules/hosting/lib/args.js b/src/puter-js/src/modules/hosting/lib/args.js new file mode 100644 index 000000000..2a5cd7474 --- /dev/null +++ b/src/puter-js/src/modules/hosting/lib/args.js @@ -0,0 +1,17 @@ +// Shared argument-shape helpers for the puter.hosting methods. + +// A subdomain may be passed either bare (`example`) or as a full hosted host +// (`example.puter.site` / `example.puter.com`); the latter is accepted so +// copy-pasting a site URL's host just works during development. +const SUBDOMAIN_HOST = /^[a-z0-9]+\.puter\.(site|com)$/; + +/** + * Returns just the subdomain label, stripping a trailing `.puter.site` / + * `.puter.com` when the value is a full hosted host. Non-string or unrelated + * values pass through unchanged. + * + * @param {unknown} value + * @returns {unknown} + */ +export const normalizeSubdomain = (value) => + typeof value === 'string' && SUBDOMAIN_HOST.test(value) ? value.split('.')[0] : value; diff --git a/src/puter-js/src/modules/hosting/list.js b/src/puter-js/src/modules/hosting/list.js new file mode 100644 index 000000000..9febbee4b --- /dev/null +++ b/src/puter-js/src/modules/hosting/list.js @@ -0,0 +1,94 @@ +import * as utils from '../../lib/utils.js'; +import { fetchAllPages, iteratePages } from '../../lib/pagination.js'; +import { PuterJSError } from '../../lib/PuterJSError.js'; + +/** @typedef {import('../../../types/modules/hosting').Subdomain} Subdomain */ + +// Older backends include worker-backed subdomain rows in select results; +// current ones exclude them server-side. Filtering here keeps the SDK's output +// stable across both. +const withoutWorkerRows = (items) => + items.filter(e => !e.subdomain.startsWith('workers.puter.')); + +/** + * @overload + * @param {import('../../../types/shared').ListStreamOptions} options + * @returns {AsyncIterableIterator>} + */ +/** + * @overload + * @param {import('../../../types/shared').ListPaginationOptions & ({ cursor: string | null } | { includeTotal: true })} options + * @returns {Promise>} + */ +/** + * @overload + * @param {{ limit?: number, offset?: number }} [options] + * @returns {Promise} + */ +/** + * Lists the subdomains the app can access, fetching page by page under the + * hood and resolving to a plain array. Passing any pagination param + * (`cursor`/`offset`/`includeTotal`) switches to a single-request page + * envelope, and `stream: true` returns an async iterator of page envelopes. + * + * Legacy callback forms `list(success, error)` and `list({ success, error })` + * are still honored: the callback fires once with the full result. + * + * @this {import('./index.js').HostingModule} + * @param {...unknown} args + * @returns {Promise | Promise> | AsyncIterableIterator>} + */ +export function list (...args) { + const { puter } = this; + const select = utils.make_driver_method([], 'puter-subdomains', undefined, 'select', { puter, readonly: true }); + + const opts = (typeof args[0] === 'object' && args[0] !== null) ? args[0] : {}; + const { limit, offset, cursor, includeTotal, stream, success, error, ...rest } = opts; + const hasCursor = Object.prototype.hasOwnProperty.call(opts, 'cursor'); + + const base = { ...rest }; + if ( limit !== undefined ) base.limit = limit; + const fetchPage = pageParams => select({ ...base, ...pageParams }); + + if ( stream === true ) { + if ( offset !== undefined ) { + throw new PuterJSError( + '`offset` cannot be combined with `stream`; pass `cursor` to resume from a position.', + 'invalid_request', + ); + } + return (async function* () { + for await ( const page of iteratePages(fetchPage, { cursor, includeTotal: includeTotal === true }) ) { + yield { ...page, items: withoutWorkerRows(page.items ?? []) }; + } + })(); + } + + // Any pagination param keeps the single-request behavior (envelope once + // the request opts in via cursor/includeTotal). + if ( limit !== undefined || offset !== undefined || hasCursor || includeTotal !== undefined ) { + return (async () => { + const result = await select(opts); + if ( result && !Array.isArray(result) && Array.isArray(result.items) ) { + return result; + } + return withoutWorkerRows(result); + })(); + } + + // Unbound listing: fetch page by page under the hood so no single request + // carries the whole result, then return the legacy array. + const promise = fetchAllPages(fetchPage).then(items => withoutWorkerRows(items)); + + // Legacy callback forms: mirror handle_resp — the callback fires once with + // the full result and the returned promise still settles the same way. + const successCb = typeof args[0] === 'function' ? args[0] : success; + const errorCb = typeof args[0] === 'function' ? args[1] : error; + if ( typeof successCb === 'function' || typeof errorCb === 'function' ) { + promise.then( + result => { if ( typeof successCb === 'function' ) successCb(result); }, + err => { if ( typeof errorCb === 'function' ) errorCb(err); }, + ); + } + return promise; +} diff --git a/src/puter-js/src/modules/hosting/update.js b/src/puter-js/src/modules/hosting/update.js new file mode 100644 index 000000000..1d85ba429 --- /dev/null +++ b/src/puter-js/src/modules/hosting/update.js @@ -0,0 +1,26 @@ +import * as utils from '../../lib/utils.js'; +import getAbsolutePathForApp from '../FileSystem/utils/getAbsolutePathForApp.js'; +import { normalizeSubdomain } from './lib/args.js'; + +/** @typedef {import('../../../types/modules/hosting').Subdomain} Subdomain */ + +/** + * Updates a subdomain to point at a new directory. Rejects if the subdomain + * or the path does not exist. Passing no `dirPath` disconnects the directory. + * + * @this {import('./index.js').HostingModule} + * @param {string} subdomain + * @param {string} [dirPath] + * @returns {Promise} + */ +export async function update (subdomain, dirPath) { + const { puter } = this; + + let options = {}; + if ( typeof subdomain === 'string' ) { + const rootDir = dirPath ? getAbsolutePathForApp(dirPath) : (dirPath ?? null); + options = { id: { subdomain: normalizeSubdomain(subdomain) }, object: { root_dir: rootDir } }; + } + + return await utils.make_driver_method(['object'], 'puter-subdomains', undefined, 'update', { puter })(options); +} diff --git a/src/puter-js/src/modules/os/index.js b/src/puter-js/src/modules/os/index.js new file mode 100644 index 000000000..7f80f71ba --- /dev/null +++ b/src/puter-js/src/modules/os/index.js @@ -0,0 +1,67 @@ +import { user } from './user.js'; +import { version } from './version.js'; + +/** @typedef {import('../../../types/puter').Puter} Puter */ + +/** + * The `puter.os` module. Holds a reference to the owning Puter instance and + * reads auth state from it live — nothing is copied out, so token and origin + * changes on the instance apply to in-flight modules immediately. + * + * Method implementations live in the sibling files as `this`-context + * functions whose JSDoc is the source of truth for the public signatures; + * types/modules/os.d.ts mirrors them for TypeScript consumers of the SDK. + */ +export class OSModule { + /** @type {Puter} */ + puter; + + user = user; + version = version; + + /** @param {Puter} puter */ + constructor (puter) { + this.puter = puter; + + const methods = /** @type {Record unknown>} */ ( + /** @type {unknown} */ (this) + ); + for ( const name of ['user', 'version'] ) { + methods[name] = methods[name].bind(this); + } + } + + // Kept for backward compatibility: these used to be copied fields kept in + // sync by set{AuthToken,APIOrigin}; they now read through live. + get authToken () { + return this.puter.authToken; + } + + get APIOrigin () { + return this.puter.APIOrigin; + } + + get appID () { + return this.puter.appID; + } + + // No-ops: auth state is read from the Puter instance at call time. The + // module registry still invokes these on token/origin changes. + setAuthToken () {} + + setAPIOrigin () {} +} + +/** + * The public face of the module: derived from the class, with the internal + * `puter` handle and the legacy `authToken` accessor omitted. + * + * @typedef {import('../../lib/types.js').OmitMembers< + * typeof OSModule, + * 'puter' | 'authToken' + * >} OSConstructor + */ + +export const OS = /** @type {OSConstructor} */ (OSModule); + +export default OS; diff --git a/src/puter-js/src/modules/os/lib/args.js b/src/puter-js/src/modules/os/lib/args.js new file mode 100644 index 000000000..5deb441ed --- /dev/null +++ b/src/puter-js/src/modules/os/lib/args.js @@ -0,0 +1,11 @@ +// Shared argument parsing for the puter.os methods. Each accepts either a +// single options object or trailing positional `success`/`error` callbacks. + +/** + * @param {unknown[]} args + * @returns {{ success?: Function, error?: Function, query?: Record }} + */ +export const parseCallbackOptions = (args) => + (typeof args[0] === 'object' && args[0] !== null) + ? args[0] + : { success: args[0], error: args[1] }; diff --git a/src/puter-js/src/modules/os/user.js b/src/puter-js/src/modules/os/user.js new file mode 100644 index 000000000..323e400f0 --- /dev/null +++ b/src/puter-js/src/modules/os/user.js @@ -0,0 +1,29 @@ +import * as utils from '../../lib/utils.js'; +import { parseCallbackOptions } from './lib/args.js'; + +/** @typedef {import('../../../types/modules/auth').User} User */ + +/** + * Returns the currently authenticated user. Accepts an options object with an + * optional `query` (forwarded as query-string params to `/whoami`) and + * `success`/`error` callbacks, or trailing positional callbacks. + * + * @this {import('./index.js').OSModule} + * @param {...(((value: User) => void) | { success?: Function, error?: Function, query?: Record })} args + * @returns {Promise} + */ +export function user (...args) { + const { puter } = this; + const options = parseCallbackOptions(args); + + let query = ''; + if ( options?.query ) { + query = `?${new URLSearchParams(options.query).toString()}`; + } + + return new Promise((resolve, reject) => { + const xhr = utils.initXhr(`/whoami${query}`, puter.APIOrigin, puter.authToken, 'get'); + utils.setupXhrEventHandlers(xhr, options.success, options.error, resolve, reject); + xhr.send(); + }); +} diff --git a/src/puter-js/src/modules/os/version.js b/src/puter-js/src/modules/os/version.js new file mode 100644 index 000000000..cf4fd131f --- /dev/null +++ b/src/puter-js/src/modules/os/version.js @@ -0,0 +1,21 @@ +import * as utils from '../../lib/utils.js'; +import { parseCallbackOptions } from './lib/args.js'; + +/** + * Returns version information about the Puter deployment. Accepts an options + * object with `success`/`error` callbacks, or trailing positional callbacks. + * + * @this {import('./index.js').OSModule} + * @param {...(((value: Record) => void) | { success?: Function, error?: Function })} args + * @returns {Promise>} + */ +export function version (...args) { + const { puter } = this; + const options = parseCallbackOptions(args); + + return new Promise((resolve, reject) => { + const xhr = utils.initXhr('/version', puter.APIOrigin, puter.authToken, 'get'); + utils.setupXhrEventHandlers(xhr, options.success, options.error, resolve, reject); + xhr.send(); + }); +} diff --git a/src/puter-js/src/modules/perms/appRootDir.js b/src/puter-js/src/modules/perms/appRootDir.js new file mode 100644 index 000000000..177191236 --- /dev/null +++ b/src/puter-js/src/modules/perms/appRootDir.js @@ -0,0 +1,78 @@ +import { PuterJSError } from '../../lib/PuterJSError.js'; +import { req } from './lib/req.js'; + +/** @typedef {import('./index.js').PermsModule} PermsModule */ + +/** + * Requests access at the given level to the root directory of one of the + * user's apps. Tries the request first; if it fails, prompts for the + * permission and retries (with a short backoff to ride out server-side cache + * invalidation), returning the fs item on success or `undefined` if denied. + * + * @param {import('../../../types/puter').Puter} puter + * @param {'read' | 'write'} access + * @param {string | { uid: string }} appUidOrObject + * @returns {Promise | undefined>} + */ +async function requestAppRootDir (puter, access, appUidOrObject) { + const appUid = (typeof appUidOrObject === 'object' && appUidOrObject !== null) + ? appUidOrObject.uid + : appUidOrObject; + + if ( typeof appUid !== 'string' ) { + throw new PuterJSError('parameter app_uid must be a string', 'invalid_argument'); + } + + let result; + const fetchIt = async () => { + result = await req(puter, '/auth/request-app-root-dir', { app_uid: appUid, access }); + }; + + await fetchIt(); + if ( ! result.error ) return result; + + const granted = await puter.ui.requestPermission({ + permission: `app-root-dir:${appUid}:${access}`, + }); + + if ( granted ) { + await fetchIt(); + + // If the server has cache-invalidation lag, retry with backoff so this + // still works. A hack, but also a reasonable safeguard. + let delay = 100; + const maxTotalWait = 5000; + let totalWaited = 0; + while ( result.error && totalWaited < maxTotalWait ) { + await new Promise(r => setTimeout(r, delay)); + totalWaited += delay; + await fetchIt(); + if ( ! result.error ) break; + delay = Math.min(delay * 2, Math.max(100, maxTotalWait - totalWaited)); + } + } + + return result.error ? undefined : result; +} + +/** + * Request read access to the root directory of one of the user's apps. + * + * @this {PermsModule} + * @param {string | { uid: string }} appUid - The app uid, or an object with a `uid`. + * @returns {Promise | undefined>} The directory fs item, or `undefined` if denied. + */ +export async function requestReadAppRootDir (appUid) { + return await requestAppRootDir(this.puter, 'read', appUid); +} + +/** + * Request write access to the root directory of one of the user's apps. + * + * @this {PermsModule} + * @param {string | { uid: string }} appUid - The app uid, or an object with a `uid`. + * @returns {Promise | undefined>} The directory fs item, or `undefined` if denied. + */ +export async function requestWriteAppRootDir (appUid) { + return await requestAppRootDir(this.puter, 'write', appUid); +} diff --git a/src/puter-js/src/modules/perms/appRootDir.test.js b/src/puter-js/src/modules/perms/appRootDir.test.js new file mode 100644 index 000000000..a95c62057 --- /dev/null +++ b/src/puter-js/src/modules/perms/appRootDir.test.js @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Control the shared request helper so these run without a server or UI. +const mockReq = vi.fn(); +vi.mock('./lib/req.js', () => ({ req: (...args) => mockReq(...args) })); + +const { requestReadAppRootDir, requestWriteAppRootDir } = await import('./appRootDir.js'); +const { PuterJSError } = await import('../../lib/PuterJSError.js'); + +const makeModule = (requestPermission) => ({ + puter: { + APIOrigin: 'https://api.test', + ui: { requestPermission: vi.fn(requestPermission) }, + }, +}); + +describe('perms appRootDir', () => { + beforeEach(() => mockReq.mockReset()); + + it('requests write access with access:"write" in the body', async () => { + mockReq.mockResolvedValueOnce({ path: '/root' }); // succeeds first try + const mod = makeModule(); + + const result = await requestWriteAppRootDir.call(mod, 'app-123'); + + expect(result).toEqual({ path: '/root' }); + expect(mockReq).toHaveBeenCalledWith( + mod.puter, + '/auth/request-app-root-dir', + { app_uid: 'app-123', access: 'write' }, + ); + // Already had access, so no permission prompt. + expect(mod.puter.ui.requestPermission).not.toHaveBeenCalled(); + }); + + it('requests read access with access:"read" in the body', async () => { + mockReq.mockResolvedValueOnce({ path: '/root' }); + const mod = makeModule(); + + await requestReadAppRootDir.call(mod, 'app-xyz'); + + expect(mockReq).toHaveBeenCalledWith( + mod.puter, + '/auth/request-app-root-dir', + { app_uid: 'app-xyz', access: 'read' }, + ); + }); + + it('prompts for the write permission string and retries after a grant', async () => { + // First call is denied by the backend, second (post-grant) succeeds. + mockReq + .mockResolvedValueOnce({ error: true }) + .mockResolvedValueOnce({ path: '/root' }); + const mod = makeModule(() => true); + + const result = await requestWriteAppRootDir.call(mod, 'app-123'); + + expect(mod.puter.ui.requestPermission).toHaveBeenCalledWith({ + permission: 'app-root-dir:app-123:write', + }); + expect(result).toEqual({ path: '/root' }); + expect(mockReq).toHaveBeenCalledTimes(2); + }); + + it('accepts an app object with a uid', async () => { + mockReq.mockResolvedValueOnce({ path: '/root' }); + const mod = makeModule(); + + await requestReadAppRootDir.call(mod, { uid: 'app-obj' }); + + expect(mockReq).toHaveBeenCalledWith( + mod.puter, + '/auth/request-app-root-dir', + { app_uid: 'app-obj', access: 'read' }, + ); + }); + + it('returns undefined when the permission is denied', async () => { + mockReq.mockResolvedValue({ error: true }); + const mod = makeModule(() => false); + + const result = await requestWriteAppRootDir.call(mod, 'app-123'); + + expect(result).toBeUndefined(); + }); + + it('rejects a non-string, non-object app uid with a coded error', async () => { + const mod = makeModule(); + await expect(requestReadAppRootDir.call(mod, 42)).rejects.toBeInstanceOf(PuterJSError); + await expect(requestReadAppRootDir.call(mod, 42)).rejects.toMatchObject({ + code: 'invalid_argument', + }); + }); +}); diff --git a/src/puter-js/src/modules/perms/folders.js b/src/puter-js/src/modules/perms/folders.js new file mode 100644 index 000000000..18c78879f --- /dev/null +++ b/src/puter-js/src/modules/perms/folders.js @@ -0,0 +1,71 @@ +/** @typedef {import('./index.js').PermsModule} PermsModule */ + +/** + * Requests access to one of the user's special folders, returning its path if + * access is (or becomes) granted. Read access is inferred from being able to + * stat the folder; write access always prompts if not already held. + * + * @this {PermsModule} + * @param {string} folderName - Desktop, Documents, Pictures, or Videos. + * @param {'read' | 'write'} accessLevel + * @returns {Promise} + */ +export async function requestFolder_ (folderName, accessLevel) { + const whoami = await this.puter.auth.whoami(); + const folderPath = `/${whoami.username}/${folderName}`; + + // Being able to stat the folder means we already have at least read access. + try { + await this.puter.fs.stat({ path: folderPath }); + if ( accessLevel !== 'write' ) { + return folderPath; + } + } catch (e) { + // No access yet, fall through to request permission. + } + + const granted = await this.puter.ui.requestPermission({ + permission: `fs:${folderPath}:${accessLevel}`, + }); + return granted ? folderPath : undefined; +} + +/** @this {PermsModule} @returns {Promise} */ +export function requestReadDesktop () { + return this.requestFolder_('Desktop', 'read'); +} + +/** @this {PermsModule} @returns {Promise} */ +export function requestWriteDesktop () { + return this.requestFolder_('Desktop', 'write'); +} + +/** @this {PermsModule} @returns {Promise} */ +export function requestReadDocuments () { + return this.requestFolder_('Documents', 'read'); +} + +/** @this {PermsModule} @returns {Promise} */ +export function requestWriteDocuments () { + return this.requestFolder_('Documents', 'write'); +} + +/** @this {PermsModule} @returns {Promise} */ +export function requestReadPictures () { + return this.requestFolder_('Pictures', 'read'); +} + +/** @this {PermsModule} @returns {Promise} */ +export function requestWritePictures () { + return this.requestFolder_('Pictures', 'write'); +} + +/** @this {PermsModule} @returns {Promise} */ +export function requestReadVideos () { + return this.requestFolder_('Videos', 'read'); +} + +/** @this {PermsModule} @returns {Promise} */ +export function requestWriteVideos () { + return this.requestFolder_('Videos', 'write'); +} diff --git a/src/puter-js/src/modules/perms/grants.js b/src/puter-js/src/modules/perms/grants.js new file mode 100644 index 000000000..8bee74894 --- /dev/null +++ b/src/puter-js/src/modules/perms/grants.js @@ -0,0 +1,118 @@ +import { req } from './lib/req.js'; + +/** @typedef {import('./index.js').PermsModule} PermsModule */ +/** @typedef {Promise>} PermResult */ + +// -- Grant -- + +/** + * Grants a permission to another user. + * @this {PermsModule} + * @param {string} username + * @param {string} permission + * @returns {PermResult} + */ +export async function grantUser (username, permission) { + return await req(this.puter, '/auth/grant-user-user', { target_username: username, permission }); +} + +/** + * Grants a permission to a group. + * @this {PermsModule} + * @param {string} groupUid + * @param {string} permission + * @returns {PermResult} + */ +export async function grantGroup (groupUid, permission) { + return await req(this.puter, '/auth/grant-user-group', { group_uid: groupUid, permission }); +} + +/** + * Grants a permission to an app. + * @this {PermsModule} + * @param {string} appUid + * @param {string} permission + * @returns {PermResult} + */ +export async function grantApp (appUid, permission) { + return await req(this.puter, '/auth/grant-user-app', { app_uid: appUid, permission }); +} + +/** + * Grants a permission to an app for any user (developer grant). + * @this {PermsModule} + * @param {string} appUid + * @param {string} permission + * @returns {PermResult} + */ +export async function grantAppAnyUser (appUid, permission) { + return await req(this.puter, '/auth/grant-dev-app', { app_uid: appUid, permission }); +} + +/** + * Grants a permission to an origin. + * @this {PermsModule} + * @param {string} origin + * @param {string} permission + * @returns {PermResult} + */ +export async function grantOrigin (origin, permission) { + return await req(this.puter, '/auth/grant-user-app', { origin, permission }); +} + +// -- Revoke -- + +/** + * Revokes a permission from another user. + * @this {PermsModule} + * @param {string} username + * @param {string} permission + * @returns {PermResult} + */ +export async function revokeUser (username, permission) { + return await req(this.puter, '/auth/revoke-user-user', { target_username: username, permission }); +} + +/** + * Revokes a permission from a group. + * @this {PermsModule} + * @param {string} groupUid + * @param {string} permission + * @returns {PermResult} + */ +export async function revokeGroup (groupUid, permission) { + return await req(this.puter, '/auth/revoke-user-group', { group_uid: groupUid, permission }); +} + +/** + * Revokes a permission from an app. + * @this {PermsModule} + * @param {string} appUid + * @param {string} permission + * @returns {PermResult} + */ +export async function revokeApp (appUid, permission) { + return await req(this.puter, '/auth/revoke-user-app', { app_uid: appUid, permission }); +} + +/** + * Revokes an app's any-user (developer) permission. + * @this {PermsModule} + * @param {string} appUid + * @param {string} permission + * @returns {PermResult} + */ +export async function revokeAppAnyUser (appUid, permission) { + return await req(this.puter, '/auth/revoke-dev-app', { app_uid: appUid, permission }); +} + +/** + * Revokes a permission from an origin. + * @this {PermsModule} + * @param {string} origin + * @param {string} permission + * @returns {PermResult} + */ +export async function revokeOrigin (origin, permission) { + return await req(this.puter, '/auth/revoke-user-app', { origin, permission }); +} diff --git a/src/puter-js/src/modules/perms/groups.js b/src/puter-js/src/modules/perms/groups.js new file mode 100644 index 000000000..bfea8ba2b --- /dev/null +++ b/src/puter-js/src/modules/perms/groups.js @@ -0,0 +1,46 @@ +import { req } from './lib/req.js'; + +/** @typedef {import('./index.js').PermsModule} PermsModule */ +/** @typedef {Promise>} PermResult */ + +/** + * Creates a new group. + * @this {PermsModule} + * @param {Record} [metadata] + * @param {Record} [extra] + * @returns {PermResult} + */ +export async function createGroup (metadata = {}, extra = {}) { + return await req(this.puter, '/group/create', { metadata, extra }); +} + +/** + * Adds users to a group by username. + * @this {PermsModule} + * @param {string} uid + * @param {string[]} usernames + * @returns {PermResult} + */ +export async function addUsersToGroup (uid, usernames) { + return await req(this.puter, '/group/add-users', { uid, users: usernames ?? [] }); +} + +/** + * Removes users from a group by username. + * @this {PermsModule} + * @param {string} uid + * @param {string[]} usernames + * @returns {PermResult} + */ +export async function removeUsersFromGroup (uid, usernames) { + return await req(this.puter, '/group/remove-users', { uid, users: usernames ?? [] }); +} + +/** + * Lists the caller's groups. + * @this {PermsModule} + * @returns {PermResult} + */ +export async function listGroups () { + return await req(this.puter, '/group/list'); +} diff --git a/src/puter-js/src/modules/perms/index.js b/src/puter-js/src/modules/perms/index.js new file mode 100644 index 000000000..067f45378 --- /dev/null +++ b/src/puter-js/src/modules/perms/index.js @@ -0,0 +1,148 @@ +import { requestReadAppRootDir, requestWriteAppRootDir } from './appRootDir.js'; +import { + requestFolder_, + requestReadDesktop, requestWriteDesktop, + requestReadDocuments, requestWriteDocuments, + requestReadPictures, requestWritePictures, + requestReadVideos, requestWriteVideos, +} from './folders.js'; +import { + grantApp, grantAppAnyUser, grantGroup, grantOrigin, grantUser, + revokeApp, revokeAppAnyUser, revokeGroup, revokeOrigin, revokeUser, +} from './grants.js'; +import { addUsersToGroup, createGroup, listGroups, removeUsersFromGroup } from './groups.js'; +import { req } from './lib/req.js'; +import { + request, requestEmail, requestManageApps, requestManageSubdomains, + requestPermission, requestReadApps, requestReadSubdomains, +} from './permissions.js'; + +/** @typedef {import('../../../types/puter').Puter} Puter */ + +// Every `this`-context method exposed on the module, rebound in the +// constructor so both `puter.perms.grantUser(...)` and destructured +// `const { grantUser } = puter.perms` calls keep the right `this`. +const METHODS = [ + 'grantUser', 'grantGroup', 'grantApp', 'grantAppAnyUser', 'grantOrigin', + 'revokeUser', 'revokeGroup', 'revokeApp', 'revokeAppAnyUser', 'revokeOrigin', + 'createGroup', 'addUsersToGroup', 'removeUsersFromGroup', 'listGroups', + 'request', 'requestPermission', 'requestEmail', + 'requestReadApps', 'requestManageApps', 'requestReadSubdomains', 'requestManageSubdomains', + 'requestFolder_', + 'requestReadDesktop', 'requestWriteDesktop', + 'requestReadDocuments', 'requestWriteDocuments', + 'requestReadPictures', 'requestWritePictures', + 'requestReadVideos', 'requestWriteVideos', + 'requestReadAppRootDir', 'requestWriteAppRootDir', +]; + +/** + * The `puter.perms` module. Holds a reference to the owning Puter instance and + * reads auth state from it live — nothing is copied out, so token and origin + * changes on the instance apply to in-flight modules immediately. + * + * Method implementations live in the sibling files as `this`-context + * functions whose JSDoc is the source of truth for the public signatures; + * types/modules/perms.d.ts mirrors them for TypeScript consumers of the SDK. + */ +export class PermsModule { + /** @type {Puter} */ + puter; + + // Grant / revoke + grantUser = grantUser; + grantGroup = grantGroup; + grantApp = grantApp; + grantAppAnyUser = grantAppAnyUser; + grantOrigin = grantOrigin; + revokeUser = revokeUser; + revokeGroup = revokeGroup; + revokeApp = revokeApp; + revokeAppAnyUser = revokeAppAnyUser; + revokeOrigin = revokeOrigin; + + // Group management + createGroup = createGroup; + addUsersToGroup = addUsersToGroup; + removeUsersFromGroup = removeUsersFromGroup; + listGroups = listGroups; + + // Permission requests + request = request; + requestPermission = requestPermission; + requestEmail = requestEmail; + requestReadApps = requestReadApps; + requestManageApps = requestManageApps; + requestReadSubdomains = requestReadSubdomains; + requestManageSubdomains = requestManageSubdomains; + + // Folder access + requestFolder_ = requestFolder_; + requestReadDesktop = requestReadDesktop; + requestWriteDesktop = requestWriteDesktop; + requestReadDocuments = requestReadDocuments; + requestWriteDocuments = requestWriteDocuments; + requestReadPictures = requestReadPictures; + requestWritePictures = requestWritePictures; + requestReadVideos = requestReadVideos; + requestWriteVideos = requestWriteVideos; + + // App root directory access + requestReadAppRootDir = requestReadAppRootDir; + requestWriteAppRootDir = requestWriteAppRootDir; + + /** @param {Puter} puter */ + constructor (puter) { + this.puter = puter; + + const methods = /** @type {Record unknown>} */ ( + /** @type {unknown} */ (this) + ); + for ( const name of METHODS ) { + methods[name] = methods[name].bind(this); + } + } + + /** + * Low-level request helper against the auth/group endpoints, kept on the + * instance for backward compatibility. Returns the parsed result object + * (with `error: true` set on failure) rather than rejecting. + * + * @param {string} route + * @param {Record} [body] + * @returns {Promise>} + */ + req_ (route, body) { + return req(this.puter, route, body); + } + + // Kept for backward compatibility: these used to be copied fields kept in + // sync by set{AuthToken,APIOrigin}; they now read through live. + get authToken () { + return this.puter.authToken; + } + + get APIOrigin () { + return this.puter.APIOrigin; + } + + // No-ops: auth state is read from the Puter instance at call time. The + // module registry still invokes these on token/origin changes. + setAuthToken () {} + + setAPIOrigin () {} +} + +/** + * The public face of the module: derived from the class, with the internal + * `puter` handle and the legacy `authToken` accessor omitted. + * + * @typedef {import('../../lib/types.js').OmitMembers< + * typeof PermsModule, + * 'puter' | 'authToken' + * >} PermsConstructor + */ + +export const Perms = /** @type {PermsConstructor} */ (PermsModule); + +export default Perms; diff --git a/src/puter-js/src/modules/perms/lib/req.js b/src/puter-js/src/modules/perms/lib/req.js new file mode 100644 index 000000000..b1c7810f2 --- /dev/null +++ b/src/puter-js/src/modules/perms/lib/req.js @@ -0,0 +1,35 @@ +import { fetchUrl } from '../../../lib/networkUtils.js'; + +/** + * Shared request helper for the grant/revoke/group endpoints. These endpoints + * return a parsed result object (with `error: true` set on failure) rather + * than rejecting — preserved for backward compatibility, so callers keep + * inspecting `result.error` instead of catching. + * + * @param {import('../../../../types/puter').Puter} puter + * @param {string} route + * @param {Record} [body] - When present the request is a POST. + * @returns {Promise>} + */ +export async function req (puter, route, body) { + try { + const resp = await fetchUrl(puter.APIOrigin + route, { + method: body ? 'POST' : 'GET', + includePuterAuth: true, + headers: { + 'Content-Type': 'application/json', + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + if ( resp.headers.get('content-type')?.includes('application/json') ) { + const jsonResult = await resp.json(); + if ( resp.status !== 200 ) { + jsonResult.error = true; + } + return jsonResult; + } + return { error: true, message: await resp.text(), code: 'unknown_error' }; + } catch (e) { + return { error: true, message: e.message, code: 'internal_error' }; + } +} diff --git a/src/puter-js/src/modules/perms/permissions.js b/src/puter-js/src/modules/perms/permissions.js new file mode 100644 index 000000000..d5e3e677a --- /dev/null +++ b/src/puter-js/src/modules/perms/permissions.js @@ -0,0 +1,86 @@ +/** @typedef {import('./index.js').PermsModule} PermsModule */ + +/** + * Request a specific permission string to be granted. Note that some + * permission strings are not supported and will be denied silently. + * + * @this {PermsModule} + * @param {string} permission - The permission string to request. + * @returns {Promise} `true` if the permission was granted. + */ +export async function request (permission) { + // Note: this cannot move fully off of `puter.ui` without a significant + // refactor, because the UI module owns all of the IPC communication logic. + return await this.puter.ui.requestPermission({ permission }); +} + +/** + * @deprecated Use {@link request} instead. + * @this {PermsModule} + * @param {...unknown} args + * @returns {Promise} + */ +export function requestPermission (...args) { + return this.request(...args); +} + +/** + * Request to see the user's email. If already granted, the user is not + * prompted and their email is returned. + * + * @this {PermsModule} + * @returns {Promise} The email if granted, `null` + * if granted but no email is on file, or `undefined` if access is denied. + */ +export async function requestEmail () { + let whoami = await this.puter.auth.whoami(); + if ( whoami.email !== undefined ) return whoami.email; + + const granted = await this.puter.ui.requestPermission({ + permission: `user:${whoami.uuid}:email:read`, + }); + if ( granted ) { + whoami = await this.puter.auth.whoami(); + } + return whoami.email; +} + +/** + * Request read access to the user's apps. + * @this {PermsModule} + * @returns {Promise} + */ +export async function requestReadApps () { + const whoami = await this.puter.auth.whoami(); + return await this.puter.ui.requestPermission({ permission: `apps-of-user:${whoami.uuid}:read` }); +} + +/** + * Request write (manage) access to the user's apps. + * @this {PermsModule} + * @returns {Promise} + */ +export async function requestManageApps () { + const whoami = await this.puter.auth.whoami(); + return await this.puter.ui.requestPermission({ permission: `apps-of-user:${whoami.uuid}:write` }); +} + +/** + * Request read access to the user's subdomains. + * @this {PermsModule} + * @returns {Promise} + */ +export async function requestReadSubdomains () { + const whoami = await this.puter.auth.whoami(); + return await this.puter.ui.requestPermission({ permission: `subdomains-of-user:${whoami.uuid}:read` }); +} + +/** + * Request write (manage) access to the user's subdomains. + * @this {PermsModule} + * @returns {Promise} + */ +export async function requestManageSubdomains () { + const whoami = await this.puter.auth.whoami(); + return await this.puter.ui.requestPermission({ permission: `subdomains-of-user:${whoami.uuid}:write` }); +} diff --git a/src/puter-js/test/apps.test.js b/src/puter-js/test/apps.test.js new file mode 100644 index 000000000..5846e4d75 --- /dev/null +++ b/src/puter-js/test/apps.test.js @@ -0,0 +1,130 @@ +/* eslint-disable */ +// TODO: Make these more compatible with eslint +// Hand-run these in the browser harness. They create and delete real apps +// under the signed-in account, using unique names so reruns don't collide. +const appsName = (s) => `apps-test-${s}-${Date.now()}`; + +window.appsTests = [ + { + name: "testCreateAndGet", + description: "Create an app (positional form) and read it back by name", + test: async function() { + const name = appsName('create'); + try { + const app = await puter.apps.create(name, 'https://example.com/create'); + assert(app.name === name, "created app name mismatch"); + const fetched = await puter.apps.get(name); + assert(fetched.index_url === 'https://example.com/create', "index_url mismatch"); + pass("testCreateAndGet passed"); + } catch (error) { + fail("testCreateAndGet failed:", error); + } finally { + try { await puter.apps.delete(name); } catch (e) {} + } + } + }, + { + name: "testCreateOptionsRemap", + description: "Create with camelCase options and verify snake_case fields are stored", + test: async function() { + const name = appsName('remap'); + try { + await puter.apps.create({ + name, + indexURL: 'https://example.com/remap', + maximizeOnStart: true, + filetypeAssociations: ['.txt', 'image/png'], + }); + const fetched = await puter.apps.get(name); + assert(Boolean(fetched.maximize_on_start) === true, "maximize_on_start not stored"); + assert(JSON.stringify(fetched.filetype_associations) === JSON.stringify(['.txt', 'image/png']), "filetype_associations not stored"); + pass("testCreateOptionsRemap passed"); + } catch (error) { + fail("testCreateOptionsRemap failed:", error); + } finally { + try { await puter.apps.delete(name); } catch (e) {} + } + } + }, + { + name: "testCreateValidationErrorShape", + description: "Create without a name throws a backward-compatible { code, error:{code} } error", + test: async function() { + try { + await puter.apps.create({ indexURL: 'https://example.com/x' }); + fail("testCreateValidationErrorShape failed: no error thrown"); + } catch (error) { + assert(error.code === 'invalid_request', "top-level code should be invalid_request"); + assert(error.error && error.error.code === 'invalid_request', "legacy nested error.code should be invalid_request"); + assert(error instanceof Error, "error should be an Error instance"); + pass("testCreateValidationErrorShape passed: " + error.message); + } + } + }, + { + name: "testUpdate", + description: "Update an app's index URL and verify the change", + test: async function() { + const name = appsName('update'); + try { + await puter.apps.create(name, 'https://example.com/before'); + const updated = await puter.apps.update(name, { indexURL: 'https://example.com/after' }); + assert(updated.index_url === 'https://example.com/after', "index_url not updated"); + pass("testUpdate passed"); + } catch (error) { + fail("testUpdate failed:", error); + } finally { + try { await puter.apps.delete(name); } catch (e) {} + } + } + }, + { + name: "testList", + description: "List apps and confirm a freshly created app appears", + test: async function() { + const name = appsName('list'); + try { + await puter.apps.create(name, 'https://example.com/list'); + const apps = await puter.apps.list(); + assert(Array.isArray(apps), "list should resolve to an array"); + assert(apps.some(a => a.name === name), "created app should appear in list"); + pass("testList passed"); + } catch (error) { + fail("testList failed:", error); + } finally { + try { await puter.apps.delete(name); } catch (e) {} + } + } + }, + { + name: "testCheckName", + description: "checkName reports a taken name differently from an available one", + test: async function() { + const name = appsName('checkname'); + try { + await puter.apps.create(name, 'https://example.com/cn'); + const taken = await puter.apps.checkName(name); + const available = await puter.apps.checkName(appsName('surely-free')); + assert(JSON.stringify(taken) !== JSON.stringify(available), "taken and available should differ"); + pass("testCheckName passed"); + } catch (error) { + fail("testCheckName failed:", error); + } finally { + try { await puter.apps.delete(name); } catch (e) {} + } + } + }, + { + name: "testGetDeveloperProfile", + description: "getDeveloperProfile resolves once with an object (single-parse fix)", + test: async function() { + try { + const profile = await puter.apps.getDeveloperProfile(); + assert(profile && typeof profile === 'object', "profile should be an object"); + pass("testGetDeveloperProfile passed"); + } catch (error) { + fail("testGetDeveloperProfile failed:", error); + } + } + }, +]; diff --git a/src/puter-js/test/hosting.test.js b/src/puter-js/test/hosting.test.js new file mode 100644 index 000000000..1c41228e3 --- /dev/null +++ b/src/puter-js/test/hosting.test.js @@ -0,0 +1,111 @@ +/* eslint-disable */ +// TODO: Make these more compatible with eslint +// Hand-run in the browser harness. Each test provisions its own root directory +// (the subdomain driver requires one) and cleans up the subdomain afterward. +const hostName = (s) => `hostingtest${s}${Date.now()}`; + +async function makeDir(name) { + const user = await puter.auth.getUser(); + const dir = `/${user.username}/hosting-test-${name}-${Date.now()}`; + await puter.fs.mkdir(dir, { createMissingParents: true }); + return dir; +} + +window.hostingTests = [ + { + name: "testCreateAndGet", + description: "Create a subdomain and read it back by name", + test: async function() { + const sub = hostName('cg'); + try { + const dir = await makeDir('cg'); + const created = await puter.hosting.create(sub, dir); + assert(created.subdomain === sub, "created subdomain mismatch"); + const fetched = await puter.hosting.get(sub); + assert(fetched.subdomain === sub, "fetched subdomain mismatch"); + pass("testCreateAndGet passed"); + } catch (error) { + fail("testCreateAndGet failed:", error); + } finally { + try { await puter.hosting.delete(sub); } catch (e) {} + } + } + }, + { + name: "testFullHostNormalization", + description: "Passing '.puter.site' stores just the label and is retrievable by it", + test: async function() { + const sub = hostName('fh'); + try { + const dir = await makeDir('fh'); + const created = await puter.hosting.create(`${sub}.puter.site`, dir); + assert(created.subdomain === sub, "full host should be stripped to the label"); + const byHost = await puter.hosting.get(`${sub}.puter.com`); + assert(byHost.subdomain === sub, "get should normalize a full host too"); + pass("testFullHostNormalization passed"); + } catch (error) { + fail("testFullHostNormalization failed:", error); + } finally { + try { await puter.hosting.delete(sub); } catch (e) {} + } + } + }, + { + name: "testUpdate", + description: "Repoint a subdomain to a new directory", + test: async function() { + const sub = hostName('up'); + try { + const dirA = await makeDir('up-a'); + const dirB = await makeDir('up-b'); + await puter.hosting.create(sub, dirA); + const updated = await puter.hosting.update(sub, dirB); + assert(updated.subdomain === sub, "update should return the subdomain"); + pass("testUpdate passed"); + } catch (error) { + fail("testUpdate failed:", error); + } finally { + try { await puter.hosting.delete(sub); } catch (e) {} + } + } + }, + { + name: "testList", + description: "List subdomains and confirm a created one appears", + test: async function() { + const sub = hostName('ls'); + try { + const dir = await makeDir('ls'); + await puter.hosting.create(sub, dir); + const sites = await puter.hosting.list(); + assert(Array.isArray(sites), "list should resolve to an array"); + assert(sites.some(s => s.subdomain === sub), "created subdomain should appear"); + pass("testList passed"); + } catch (error) { + fail("testList failed:", error); + } finally { + try { await puter.hosting.delete(sub); } catch (e) {} + } + } + }, + { + name: "testDelete", + description: "Delete a subdomain and confirm get then rejects", + test: async function() { + const sub = hostName('del'); + try { + const dir = await makeDir('del'); + await puter.hosting.create(sub, dir); + await puter.hosting.delete(sub); + try { + await puter.hosting.get(sub); + fail("testDelete failed: get of a deleted subdomain resolved"); + } catch (e) { + pass("testDelete passed"); + } + } catch (error) { + fail("testDelete failed:", error); + } + } + }, +]; diff --git a/src/puter-js/test/index.html b/src/puter-js/test/index.html index 63fcd63b1..debc049b2 100644 --- a/src/puter-js/test/index.html +++ b/src/puter-js/test/index.html @@ -8,6 +8,10 @@ + + + +